From 21fb1f18ee4811b789334cc32ce843b1e93e1d77 Mon Sep 17 00:00:00 2001 From: thotam <78630736+thotam@users.noreply.github.com> Date: Sun, 21 Jun 2026 18:46:37 +0700 Subject: [PATCH] feat(webhooks): add webhook management UI with delivery history & test (#1211) * feat(webhooks): add webhook management UI with delivery history & test Webhooks admin page on the web dashboard for managing inbound HTTP webhooks (llm + message kinds), backed by new admin endpoints. No schema changes (reuses migrations 000059-000061). Backend (internal/http): - GET /v1/webhooks/{id}/calls - paginated delivery history (trimmed DTO; status/limit/offset filters; ownership + tenant scoped) - GET /v1/webhooks/{id}/calls/{callId} - full single-call detail (request payload, full response, callback URL, idempotency key, timestamps) - POST /v1/webhooks/{id}/test - server-side test invocation using the admin session (no secret); dispatched by kind via RunTest() on the llm/message handlers; message tester nil-guarded (403 on Lite) Injects WebhookCallStore + SetTesters() in cmd/gateway_http_wiring.go, adds i18n key webhook.message_test_requires_standard (en/vi/zh), and unit tests for list/detail (filter, tenant isolation) and test (success/error/edition gate). Frontend (ui/web): - /webhooks admin-only page: list with revoked filter + badge, create/edit form (edition-gated message kind, Lite localhost_only lock), show-once secret dialog (create + rotate), test dialog, and delivery-history dialog with server-side pagination plus a click-through full call-detail dialog. - use-webhooks hooks, query keys, types, routing, sidebar entry (Cable icon to distinguish from the existing event "Hooks" page), webhooks i18n namespace (en/vi/zh). * fix(webhooks): validate admin message tests --------- Co-authored-by: Goon --- cmd/gateway_http_wiring.go | 18 +- internal/http/webhooks_admin.go | 356 +++++++++++++++- internal/http/webhooks_admin_calls_test.go | 388 ++++++++++++++++++ internal/http/webhooks_admin_test.go | 2 +- internal/http/webhooks_llm.go | 131 ++++++ internal/http/webhooks_message.go | 123 ++++++ internal/i18n/catalog_en.go | 45 +- internal/i18n/catalog_vi.go | 45 +- internal/i18n/catalog_zh.go | 45 +- internal/i18n/keys.go | 45 +- ui/web/src/components/layout/sidebar.tsx | 4 + ui/web/src/i18n/index.ts | 7 + ui/web/src/i18n/locales/en/sidebar.json | 1 + ui/web/src/i18n/locales/en/webhooks.json | 151 +++++++ ui/web/src/i18n/locales/vi/sidebar.json | 1 + ui/web/src/i18n/locales/vi/webhooks.json | 151 +++++++ ui/web/src/i18n/locales/zh/sidebar.json | 1 + ui/web/src/i18n/locales/zh/webhooks.json | 151 +++++++ ui/web/src/lib/query-keys.ts | 5 + ui/web/src/lib/routes.ts | 1 + .../src/pages/webhooks/hooks/use-webhooks.ts | 137 +++++++ .../webhooks/webhook-call-detail-dialog.tsx | 113 +++++ .../pages/webhooks/webhook-calls-dialog.tsx | 182 ++++++++ .../pages/webhooks/webhook-form-dialog.tsx | 152 +++++++ .../pages/webhooks/webhook-form-fields.tsx | 192 +++++++++ .../src/pages/webhooks/webhook-list-table.tsx | 151 +++++++ .../pages/webhooks/webhook-secret-dialog.tsx | 79 ++++ .../pages/webhooks/webhook-test-dialog.tsx | 194 +++++++++ ui/web/src/pages/webhooks/webhooks-page.tsx | 182 ++++++++ ui/web/src/routes.tsx | 4 + ui/web/src/types/webhook.ts | 127 ++++++ 31 files changed, 3087 insertions(+), 97 deletions(-) create mode 100644 internal/http/webhooks_admin_calls_test.go create mode 100644 ui/web/src/i18n/locales/en/webhooks.json create mode 100644 ui/web/src/i18n/locales/vi/webhooks.json create mode 100644 ui/web/src/i18n/locales/zh/webhooks.json create mode 100644 ui/web/src/pages/webhooks/hooks/use-webhooks.ts create mode 100644 ui/web/src/pages/webhooks/webhook-call-detail-dialog.tsx create mode 100644 ui/web/src/pages/webhooks/webhook-calls-dialog.tsx create mode 100644 ui/web/src/pages/webhooks/webhook-form-dialog.tsx create mode 100644 ui/web/src/pages/webhooks/webhook-form-fields.tsx create mode 100644 ui/web/src/pages/webhooks/webhook-list-table.tsx create mode 100644 ui/web/src/pages/webhooks/webhook-secret-dialog.tsx create mode 100644 ui/web/src/pages/webhooks/webhook-test-dialog.tsx create mode 100644 ui/web/src/pages/webhooks/webhooks-page.tsx create mode 100644 ui/web/src/types/webhook.ts diff --git a/cmd/gateway_http_wiring.go b/cmd/gateway_http_wiring.go index 35eb9dfd..c5e8db77 100644 --- a/cmd/gateway_http_wiring.go +++ b/cmd/gateway_http_wiring.go @@ -176,9 +176,13 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( // Webhook admin CRUD — available in all editions (Standard + Lite). // Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06. + // adminH is captured so the test endpoint (POST /v1/webhooks/{id}/test) can be wired + // with the runtime invokers (llm/message handlers) once they are constructed below. + var adminH *httpapi.WebhooksAdminHandler if d.pgStores != nil && d.pgStores.Webhooks != nil { - adminH := httpapi.NewWebhooksAdminHandler( + adminH = httpapi.NewWebhooksAdminHandler( d.pgStores.Webhooks, + d.pgStores.WebhookCalls, d.pgStores.Tenants, d.msgBus, ) @@ -188,13 +192,14 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( // Webhook message endpoint — Standard edition only (channels required). // Phase 05b: POST /v1/webhooks/message → sync channel send (text + optional media). + var msgH *httpapi.WebhookMessageHandler if edition.Current().AllowsChannels() && d.pgStores != nil && d.pgStores.Webhooks != nil && d.pgStores.WebhookCalls != nil && d.pgStores.ChannelInstances != nil && d.channelMgr != nil { - msgH := httpapi.NewWebhookMessageHandler( + msgH = httpapi.NewWebhookMessageHandler( d.channelMgr, d.pgStores.ChannelInstances, d.pgStores.WebhookCalls, @@ -209,11 +214,12 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( // Phase 06: POST /v1/webhooks/llm → sync agent run (≤30s) or async enqueue. // LocalhostOnly enforcement is handled by WebhookAuthMiddleware at request time. // lane=nil → handler self-creates internal default lane (4-slot). + var llmH *httpapi.WebhookLLMHandler if d.pgStores != nil && d.pgStores.Webhooks != nil && d.pgStores.WebhookCalls != nil && d.agentRouter != nil { - llmH := httpapi.NewWebhookLLMHandler( + llmH = httpapi.NewWebhookLLMHandler( d.agentRouter, d.pgStores.WebhookCalls, d.pgStores.Webhooks, @@ -223,6 +229,12 @@ func (d *gatewayDeps) wireHTTPHandlersOnServer( llmH.SetEncKey(webhookEncKey) // K6: decrypt secret at HMAC verify time d.server.SetWebhookLLMHandler(llmH) } + + // Wire the admin test endpoint with runtime invokers. msgH is nil on Lite — the + // admin handler guards on nil and rejects message tests there. + if adminH != nil { + adminH.SetTesters(llmH, msgH) + } } // Allow browser-paired users to access HTTP APIs diff --git a/internal/http/webhooks_admin.go b/internal/http/webhooks_admin.go index 5c63e9ae..64ac0354 100644 --- a/internal/http/webhooks_admin.go +++ b/internal/http/webhooks_admin.go @@ -1,12 +1,14 @@ package http import ( + "context" "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/hex" "log/slog" "net/http" + "strconv" "time" "github.com/google/uuid" @@ -30,21 +32,38 @@ var webhookKinds = map[string]bool{ "message": true, } +// webhookLLMTester runs a server-side test invocation of an llm-kind webhook using the +// admin's already-authorized session (no webhook secret required). Implemented by *WebhookLLMHandler. +type webhookLLMTester interface { + RunTest(ctx context.Context, wh *store.WebhookData, input, model string) (*webhookLLMSyncResp, error) +} + +// webhookMsgTester runs a server-side test invocation of a message-kind webhook. Implemented by +// *WebhookMessageHandler. nil on Lite edition (channels unavailable) — handleTest guards on nil. +type webhookMsgTester interface { + RunTest(ctx context.Context, wh *store.WebhookData, req webhookMessageReq) (*webhookMessageResp, error) +} + // WebhooksAdminHandler implements CRUD for webhook registry entries. // All endpoints are tenant-admin-gated (requireTenantAdmin). // encKey is the AES-256-GCM encryption key (GOCLAW_ENCRYPTION_KEY); if empty, encrypted_secret // is stored as "" and HMAC auth requires rotation before it can be used. type WebhooksAdminHandler struct { - webhooks store.WebhookStore - tenants store.TenantStore - msgBus *bus.MessageBus - encKey string // AES-256-GCM key for encrypting raw webhook secrets at rest + webhooks store.WebhookStore + calls store.WebhookCallStore + tenants store.TenantStore + msgBus *bus.MessageBus + encKey string // AES-256-GCM key for encrypting raw webhook secrets at rest + llmTester webhookLLMTester + msgTester webhookMsgTester } // NewWebhooksAdminHandler creates a handler for webhook admin endpoints. -func NewWebhooksAdminHandler(webhooks store.WebhookStore, tenants store.TenantStore, msgBus *bus.MessageBus) *WebhooksAdminHandler { +// calls may be nil — the delivery-history endpoint returns 503 when unset. +func NewWebhooksAdminHandler(webhooks store.WebhookStore, calls store.WebhookCallStore, tenants store.TenantStore, msgBus *bus.MessageBus) *WebhooksAdminHandler { return &WebhooksAdminHandler{ webhooks: webhooks, + calls: calls, tenants: tenants, msgBus: msgBus, } @@ -56,6 +75,18 @@ func (h *WebhooksAdminHandler) SetEncKey(encKey string) { h.encKey = encKey } +// SetTesters wires the runtime invocation handlers used by POST /v1/webhooks/{id}/test. +// Concrete pointer params (not interfaces) so callers can pass typed-nil safely: a nil +// *WebhookMessageHandler on Lite leaves msgTester as a true nil interface. Safe to call at startup only. +func (h *WebhooksAdminHandler) SetTesters(llm *WebhookLLMHandler, msg *WebhookMessageHandler) { + if llm != nil { + h.llmTester = llm + } + if msg != nil { + h.msgTester = msg + } +} + // RegisterRoutes registers all webhook admin routes on mux. // Admin CRUD routes mount for both editions. // Runtime routes (/v1/webhooks/message, /v1/webhooks/llm) are mounted by phases 05/06 @@ -67,6 +98,9 @@ func (h *WebhooksAdminHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("PATCH /v1/webhooks/{id}", h.requireAdmin(h.handleUpdate)) mux.HandleFunc("POST /v1/webhooks/{id}/rotate", h.requireAdmin(h.handleRotate)) mux.HandleFunc("DELETE /v1/webhooks/{id}", h.requireAdmin(h.handleRevoke)) + mux.HandleFunc("GET /v1/webhooks/{id}/calls", h.requireAdmin(h.handleListCalls)) + mux.HandleFunc("GET /v1/webhooks/{id}/calls/{callId}", h.requireAdmin(h.handleGetCall)) + mux.HandleFunc("POST /v1/webhooks/{id}/test", h.requireAdmin(h.handleTest)) } func (h *WebhooksAdminHandler) requireAdmin(next http.HandlerFunc) http.HandlerFunc { @@ -532,6 +566,318 @@ func (h *WebhooksAdminHandler) handleRevoke(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) } +// --- List Calls (delivery history) --- + +// webhookCallResp is the trimmed delivery-history record returned to the admin UI. +// Raw request_payload is omitted (may be large / hold caller data); response is the +// already-truncated (≤32 KB) audit body and is safe to surface for debugging. +type webhookCallResp struct { + ID uuid.UUID `json:"id"` + DeliveryID uuid.UUID `json:"delivery_id"` + Mode string `json:"mode"` + Status string `json:"status"` + Attempts int `json:"attempts"` + NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastError *string `json:"last_error,omitempty"` + Response string `json:"response,omitempty"` +} + +const ( + webhookCallsDefaultLimit = 50 + webhookCallsMaxLimit = 200 +) + +func (h *WebhooksAdminHandler) handleListCalls(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + + if !requireTenantAdmin(w, r, h.tenants) { + slog.Warn("security.webhook.admin_denied", "action", "list_calls", "path", r.URL.Path, + "user_id", store.UserIDFromContext(r.Context())) + return + } + + if h.calls == nil { + writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "call store unavailable")) + return + } + + id, ok := parseWebhookID(w, r, locale) + if !ok { + return + } + + ctx := r.Context() + + // Verify ownership before exposing call history (same pattern as handleGet). + wh, err := h.webhooks.GetByID(ctx, id) + if err != nil || wh == nil { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String())) + return + } + tenantID := store.TenantIDFromContext(ctx) + if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String())) + return + } + + f := store.WebhookCallListFilter{WebhookID: &id, Limit: webhookCallsDefaultLimit} + if s := r.URL.Query().Get("status"); s != "" { + f.Status = s + } + if l := r.URL.Query().Get("limit"); l != "" { + if n, perr := strconv.Atoi(l); perr == nil && n > 0 { + if n > webhookCallsMaxLimit { + n = webhookCallsMaxLimit + } + f.Limit = n + } + } + if o := r.URL.Query().Get("offset"); o != "" { + if n, perr := strconv.Atoi(o); perr == nil && n >= 0 { + f.Offset = n + } + } + + rows, err := h.calls.List(ctx, f) + if err != nil { + slog.Error("webhook.admin.list_calls_failed", "error", err, "id", id) + writeError(w, http.StatusInternalServerError, protocol.ErrInternal, i18n.T(locale, i18n.MsgFailedToList, "webhook calls")) + return + } + + out := make([]webhookCallResp, 0, len(rows)) + for i := range rows { + c := &rows[i] + out = append(out, webhookCallResp{ + ID: c.ID, + DeliveryID: c.DeliveryID, + Mode: c.Mode, + Status: c.Status, + Attempts: c.Attempts, + NextAttemptAt: c.NextAttemptAt, + StartedAt: c.StartedAt, + CompletedAt: c.CompletedAt, + CreatedAt: c.CreatedAt, + LastError: c.LastError, + Response: string(c.Response), + }) + } + writeJSON(w, http.StatusOK, out) +} + +// --- Get Call (single delivery detail) --- + +// webhookCallDetailResp is the full delivery record for GET /v1/webhooks/{id}/calls/{callId}. +// Unlike the list DTO it includes request_payload (canonical audit body) and the full response, +// plus callback_url / idempotency_key — for debugging a single invocation. +type webhookCallDetailResp struct { + ID uuid.UUID `json:"id"` + WebhookID uuid.UUID `json:"webhook_id"` + AgentID *uuid.UUID `json:"agent_id,omitempty"` + DeliveryID uuid.UUID `json:"delivery_id"` + IdempotencyKey *string `json:"idempotency_key,omitempty"` + Mode string `json:"mode"` + Status string `json:"status"` + CallbackURL *string `json:"callback_url,omitempty"` + Attempts int `json:"attempts"` + NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastError *string `json:"last_error,omitempty"` + RequestPayload string `json:"request_payload,omitempty"` + Response string `json:"response,omitempty"` +} + +func (h *WebhooksAdminHandler) handleGetCall(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + + if !requireTenantAdmin(w, r, h.tenants) { + slog.Warn("security.webhook.admin_denied", "action", "get_call", "path", r.URL.Path, + "user_id", store.UserIDFromContext(r.Context())) + return + } + + if h.calls == nil { + writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "call store unavailable")) + return + } + + id, ok := parseWebhookID(w, r, locale) + if !ok { + return + } + callID, err := uuid.Parse(r.PathValue("callId")) + if err != nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidID, "call")) + return + } + + ctx := r.Context() + + // Verify webhook ownership first (same pattern as handleGet/handleListCalls). + wh, err := h.webhooks.GetByID(ctx, id) + if err != nil || wh == nil { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String())) + return + } + tenantID := store.TenantIDFromContext(ctx) + if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String())) + return + } + + // GetByID is tenant-scoped; also verify the call belongs to THIS webhook. + c, err := h.calls.GetByID(ctx, callID) + if err != nil || c == nil || c.WebhookID != id { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook call", callID.String())) + return + } + if !store.IsOwnerRole(ctx) && c.TenantID != tenantID { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook call", callID.String())) + return + } + + writeJSON(w, http.StatusOK, webhookCallDetailResp{ + ID: c.ID, + WebhookID: c.WebhookID, + AgentID: c.AgentID, + DeliveryID: c.DeliveryID, + IdempotencyKey: c.IdempotencyKey, + Mode: c.Mode, + Status: c.Status, + CallbackURL: c.CallbackURL, + Attempts: c.Attempts, + NextAttemptAt: c.NextAttemptAt, + StartedAt: c.StartedAt, + CompletedAt: c.CompletedAt, + CreatedAt: c.CreatedAt, + LastError: c.LastError, + RequestPayload: string(c.RequestPayload), + Response: string(c.Response), + }) +} + +// --- Test (server-side invocation) --- + +// testWebhookReq is the request body for POST /v1/webhooks/{id}/test. +// For kind=llm: input (+ optional model). For kind=message: channel_name/chat_id/content/media_*. +type testWebhookReq struct { + // llm fields + Input string `json:"input,omitempty"` + Model string `json:"model,omitempty"` + // message fields + ChannelName string `json:"channel_name,omitempty"` + ChatID string `json:"chat_id,omitempty"` + Content string `json:"content,omitempty"` + MediaURL string `json:"media_url,omitempty"` + MediaCaption string `json:"media_caption,omitempty"` + FallbackToText bool `json:"fallback_to_text,omitempty"` +} + +func (h *WebhooksAdminHandler) handleTest(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + + if !requireTenantAdmin(w, r, h.tenants) { + slog.Warn("security.webhook.admin_denied", "action", "test", "path", r.URL.Path, + "user_id", store.UserIDFromContext(r.Context())) + return + } + + id, ok := parseWebhookID(w, r, locale) + if !ok { + return + } + + ctx := r.Context() + + // Verify ownership before invoking. + wh, err := h.webhooks.GetByID(ctx, id) + if err != nil || wh == nil { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String())) + return + } + tenantID := store.TenantIDFromContext(ctx) + if !store.IsOwnerRole(ctx) && wh.TenantID != tenantID { + writeError(w, http.StatusNotFound, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "webhook", id.String())) + return + } + if wh.Revoked { + writeError(w, http.StatusConflict, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgWebhookRevoked)) + return + } + + var req testWebhookReq + if !bindJSON(w, r, locale, &req) { + return + } + + switch wh.Kind { + case "llm": + if h.llmTester == nil { + writeError(w, http.StatusServiceUnavailable, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, "llm tester unavailable")) + return + } + if wh.AgentID == nil { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgWebhookAgentNotFound)) + return + } + if req.Input == "" { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "input")) + return + } + resp, runErr := h.llmTester.RunTest(ctx, wh, req.Input, req.Model) + if runErr != nil { + slog.Warn("webhook.admin.test_failed", "id", id, "kind", "llm", "error", runErr) + writeError(w, http.StatusBadGateway, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, runErr.Error())) + return + } + slog.Info("webhook.tested", "id", id, "tenant_id", tenantID, "kind", "llm", "actor", extractUserID(r)) + writeJSON(w, http.StatusOK, resp) + case "message": + if h.msgTester == nil { + writeError(w, http.StatusForbidden, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgWebhookMessageTestRequiresStandard)) + return + } + if wh.ChannelID == nil && req.ChannelName == "" { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "channel_name")) + return + } + if req.ChatID == "" { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "chat_id")) + return + } + if req.Content == "" && req.MediaURL == "" { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgRequired, "content")) + return + } + if len(req.Content) > webhookContentMaxBytes { + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "content exceeds 16 KB limit")) + return + } + resp, runErr := h.msgTester.RunTest(ctx, wh, webhookMessageReq{ + ChannelName: req.ChannelName, + ChatID: req.ChatID, + Content: req.Content, + MediaURL: req.MediaURL, + MediaCaption: req.MediaCaption, + FallbackToText: req.FallbackToText, + }) + if runErr != nil { + slog.Warn("webhook.admin.test_failed", "id", id, "kind", "message", "error", runErr) + writeError(w, http.StatusBadGateway, protocol.ErrInternal, i18n.T(locale, i18n.MsgInternalError, runErr.Error())) + return + } + slog.Info("webhook.tested", "id", id, "tenant_id", tenantID, "kind", "message", "actor", extractUserID(r)) + writeJSON(w, http.StatusOK, resp) + default: + writeError(w, http.StatusBadRequest, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidRequest, "unknown webhook kind")) + } +} + // --- Helpers --- // generateWebhookSecret creates a new webhook secret in format "wh_". diff --git a/internal/http/webhooks_admin_calls_test.go b/internal/http/webhooks_admin_calls_test.go new file mode 100644 index 00000000..112bddea --- /dev/null +++ b/internal/http/webhooks_admin_calls_test.go @@ -0,0 +1,388 @@ +package http + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "net/http" + "sync" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ---- stub WebhookCallStore for admin tests ---- + +type adminCallStore struct { + mu sync.Mutex + rows []store.WebhookCallData + calls int // List invocation counter +} + +func (s *adminCallStore) Create(_ context.Context, c *store.WebhookCallData) error { + s.mu.Lock() + defer s.mu.Unlock() + s.rows = append(s.rows, *c) + return nil +} + +func (s *adminCallStore) List(ctx context.Context, f store.WebhookCallListFilter) ([]store.WebhookCallData, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.calls++ + tid := store.TenantIDFromContext(ctx) + var out []store.WebhookCallData + for _, r := range s.rows { + // Mirror real store tenant scoping. + if tid != uuid.Nil && r.TenantID != tid && !store.IsOwnerRole(ctx) { + continue + } + if f.WebhookID != nil && r.WebhookID != *f.WebhookID { + continue + } + if f.Status != "" && r.Status != f.Status { + continue + } + out = append(out, r) + } + return out, nil +} + +func (s *adminCallStore) GetByID(ctx context.Context, id uuid.UUID) (*store.WebhookCallData, error) { + s.mu.Lock() + defer s.mu.Unlock() + tid := store.TenantIDFromContext(ctx) + for i := range s.rows { + if s.rows[i].ID != id { + continue + } + if tid != uuid.Nil && s.rows[i].TenantID != tid && !store.IsOwnerRole(ctx) { + return nil, sql.ErrNoRows + } + cp := s.rows[i] + return &cp, nil + } + return nil, sql.ErrNoRows +} +func (s *adminCallStore) GetByIdempotency(context.Context, uuid.UUID, string) (*store.WebhookCallData, error) { + return nil, sql.ErrNoRows +} +func (s *adminCallStore) UpdateStatus(context.Context, uuid.UUID, map[string]any) error { return nil } +func (s *adminCallStore) UpdateStatusCAS(context.Context, uuid.UUID, string, map[string]any) error { + return nil +} +func (s *adminCallStore) ClaimNext(context.Context, uuid.UUID, time.Time) (*store.WebhookCallData, error) { + return nil, sql.ErrNoRows +} +func (s *adminCallStore) DeleteOlderThan(context.Context, uuid.UUID, time.Time) (int64, error) { + return 0, nil +} +func (s *adminCallStore) ReclaimStale(context.Context, time.Time) (int64, error) { return 0, nil } + +// ---- stub testers ---- + +type stubLLMTester struct { + resp *webhookLLMSyncResp + err error + gotWh *store.WebhookData + gotIn string + gotMdl string + invoked bool +} + +func (s *stubLLMTester) RunTest(_ context.Context, wh *store.WebhookData, input, model string) (*webhookLLMSyncResp, error) { + s.invoked = true + s.gotWh = wh + s.gotIn = input + s.gotMdl = model + return s.resp, s.err +} + +type stubMsgTester struct { + resp *webhookMessageResp + err error + invoked bool +} + +func (s *stubMsgTester) RunTest(_ context.Context, _ *store.WebhookData, _ webhookMessageReq) (*webhookMessageResp, error) { + s.invoked = true + return s.resp, s.err +} + +// ---- helpers ---- + +func newAdminHandlerWithCalls(ws *adminWebhookStore, cs store.WebhookCallStore, ts *adminTenantStore) *WebhooksAdminHandler { + h := NewWebhooksAdminHandler(ws, cs, ts, nil) + h.SetEncKey(testAdminEncKey) + return h +} + +// ---- list-calls tests ---- + +func TestWebhookAdmin_ListCalls_Success(t *testing.T) { + tid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm"} + cs := &adminCallStore{} + now := time.Now() + done := "done" + cs.rows = []store.WebhookCallData{ + {ID: uuid.New(), TenantID: tid, WebhookID: wh.ID, DeliveryID: uuid.New(), Mode: "sync", Status: "done", Attempts: 1, CreatedAt: now, Response: []byte(`{"output":"hi"}`)}, + {ID: uuid.New(), TenantID: tid, WebhookID: wh.ID, DeliveryID: uuid.New(), Mode: "async", Status: "failed", Attempts: 3, CreatedAt: now, LastError: &done}, + {ID: uuid.New(), TenantID: uuid.New(), WebhookID: uuid.New(), DeliveryID: uuid.New(), Mode: "sync", Status: "done", CreatedAt: now}, // other tenant/webhook + } + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{tid.String() + ":admin-user": "admin"}}) + + ctx := webhookTenantAdminCtx(tid, "admin-user") + w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls", nil, ctx) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + var got []webhookCallResp + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got) != 2 { + t.Fatalf("want 2 calls for this webhook, got %d", len(got)) + } +} + +func TestWebhookAdmin_ListCalls_StatusFilter(t *testing.T) { + tid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm"} + cs := &adminCallStore{} + now := time.Now() + cs.rows = []store.WebhookCallData{ + {ID: uuid.New(), TenantID: tid, WebhookID: wh.ID, Status: "done", CreatedAt: now}, + {ID: uuid.New(), TenantID: tid, WebhookID: wh.ID, Status: "failed", CreatedAt: now}, + } + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{tid.String() + ":admin-user": "admin"}}) + + ctx := webhookTenantAdminCtx(tid, "admin-user") + w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls?status=failed", nil, ctx) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d", w.Code) + } + var got []webhookCallResp + _ = json.Unmarshal(w.Body.Bytes(), &got) + if len(got) != 1 || got[0].Status != "failed" { + t.Fatalf("status filter failed: %+v", got) + } +} + +func TestWebhookAdmin_ListCalls_TenantIsolation(t *testing.T) { + ownerTid := uuid.New() + otherTid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: ownerTid, Name: "wh", Kind: "llm"} + cs := &adminCallStore{} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{ + ownerTid.String() + ":owner-user": "admin", + otherTid.String() + ":other": "admin", // valid admin of a different tenant + }}) + + // A different tenant's admin must not see the webhook (tenant-scoped GetByID → 404). + ctx := webhookTenantAdminCtx(otherTid, "other") + w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls", nil, ctx) + if w.Code != http.StatusNotFound { + t.Fatalf("cross-tenant must get 404, got %d", w.Code) + } +} + +func TestWebhookAdmin_ListCalls_NilStore503(t *testing.T) { + tid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm"} + h := newAdminHandler(newAdminWebhookStore(wh), &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) // calls == nil + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls", nil, ctx) + if w.Code != http.StatusServiceUnavailable { + t.Fatalf("nil call store must get 503, got %d", w.Code) + } +} + +// ---- get-call (detail) tests ---- + +func TestWebhookAdmin_GetCall_Success(t *testing.T) { + tid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm"} + callID := uuid.New() + cb := "https://example.com/cb" + cs := &adminCallStore{} + cs.rows = []store.WebhookCallData{{ + ID: callID, TenantID: tid, WebhookID: wh.ID, DeliveryID: uuid.New(), Mode: "async", + Status: "done", Attempts: 1, CallbackURL: &cb, CreatedAt: time.Now(), + RequestPayload: []byte(`{"body_hash":"abc","meta":{}}`), Response: []byte(`{"output":"ok"}`), + }} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) + + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls/"+callID.String(), nil, ctx) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + var got webhookCallDetailResp + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.ID != callID || got.RequestPayload == "" || got.Response == "" || got.CallbackURL == nil { + t.Fatalf("detail missing fields: %+v", got) + } +} + +func TestWebhookAdmin_GetCall_WrongWebhook404(t *testing.T) { + tid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm"} + other := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "other", Kind: "llm"} + callID := uuid.New() + cs := &adminCallStore{} + // Call belongs to `other`, not `wh`. + cs.rows = []store.WebhookCallData{{ID: callID, TenantID: tid, WebhookID: other.ID, CreatedAt: time.Now()}} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh, other), cs, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) + + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls/"+callID.String(), nil, ctx) + if w.Code != http.StatusNotFound { + t.Fatalf("call of another webhook must be 404, got %d", w.Code) + } +} + +func TestWebhookAdmin_GetCall_TenantIsolation(t *testing.T) { + ownerTid := uuid.New() + otherTid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: ownerTid, Name: "wh", Kind: "llm"} + callID := uuid.New() + cs := &adminCallStore{} + cs.rows = []store.WebhookCallData{{ID: callID, TenantID: ownerTid, WebhookID: wh.ID, CreatedAt: time.Now()}} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{ + ownerTid.String() + ":owner": "admin", + otherTid.String() + ":other": "admin", + }}) + + ctx := webhookTenantAdminCtx(otherTid, "other") + w := doRequest(t, h, http.MethodGet, "/v1/webhooks/"+wh.ID.String()+"/calls/"+callID.String(), nil, ctx) + if w.Code != http.StatusNotFound { + t.Fatalf("cross-tenant must be 404, got %d", w.Code) + } +} + +// ---- test-endpoint tests ---- + +func TestWebhookAdmin_Test_LLM_Success(t *testing.T) { + tid := uuid.New() + agentID := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm", AgentID: &agentID} + cs := &adminCallStore{} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), cs, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) + llm := &stubLLMTester{resp: &webhookLLMSyncResp{CallID: "c1", AgentID: agentID.String(), Output: "pong", FinishReason: "stop"}} + h.llmTester = llm + + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"input": "ping"}, ctx) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + if !llm.invoked || llm.gotIn != "ping" { + t.Fatalf("tester not invoked correctly: %+v", llm) + } +} + +func TestWebhookAdmin_Test_LLM_MissingInput(t *testing.T) { + tid := uuid.New() + agentID := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm", AgentID: &agentID} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) + h.llmTester = &stubLLMTester{} + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{}, ctx) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing input must be 400, got %d", w.Code) + } +} + +func TestWebhookAdmin_Test_LLM_RunError(t *testing.T) { + tid := uuid.New() + agentID := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm", AgentID: &agentID} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) + h.llmTester = &stubLLMTester{err: errors.New("boom")} + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"input": "x"}, ctx) + if w.Code != http.StatusBadGateway { + t.Fatalf("run error must be 502, got %d", w.Code) + } +} + +func TestWebhookAdmin_Test_Message_RequiresStandard(t *testing.T) { + tid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "message"} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) + // msgTester left nil → message test must be rejected. + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"chat_id": "123", "content": "hi"}, ctx) + if w.Code != http.StatusForbidden { + t.Fatalf("nil msgTester must be 403, got %d", w.Code) + } +} + +func TestWebhookAdmin_Test_Message_Success(t *testing.T) { + tid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "message"} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) + msg := &stubMsgTester{resp: &webhookMessageResp{CallID: "c1", Status: "sent", ChannelName: "tg", ChatID: "123"}} + h.msgTester = msg + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"channel_name": "tg", "chat_id": "123", "content": "hi"}, ctx) + if w.Code != http.StatusOK { + t.Fatalf("want 200, got %d: %s", w.Code, w.Body.String()) + } + if !msg.invoked { + t.Fatal("msg tester not invoked") + } +} + +func TestWebhookAdmin_Test_Message_MissingChannelName(t *testing.T) { + tid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "message"} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) + msg := &stubMsgTester{resp: &webhookMessageResp{CallID: "c1", Status: "sent", ChannelName: "tg", ChatID: "123"}} + h.msgTester = msg + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"chat_id": "123", "content": "hi"}, ctx) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing channel_name must be 400, got %d", w.Code) + } + if msg.invoked { + t.Fatal("msg tester must not run when channel_name is missing") + } +} + +func TestWebhookAdmin_Test_Message_MissingContent(t *testing.T) { + tid := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "message"} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) + msg := &stubMsgTester{resp: &webhookMessageResp{CallID: "c1", Status: "sent", ChannelName: "tg", ChatID: "123"}} + h.msgTester = msg + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"channel_name": "tg", "chat_id": "123"}, ctx) + if w.Code != http.StatusBadRequest { + t.Fatalf("missing content must be 400, got %d", w.Code) + } + if msg.invoked { + t.Fatal("msg tester must not run when content is missing") + } +} + +func TestWebhookAdmin_Test_Revoked409(t *testing.T) { + tid := uuid.New() + agentID := uuid.New() + wh := &store.WebhookData{ID: uuid.New(), TenantID: tid, Name: "wh", Kind: "llm", AgentID: &agentID, Revoked: true} + h := newAdminHandlerWithCalls(newAdminWebhookStore(wh), &adminCallStore{}, &adminTenantStore{roles: map[string]string{tid.String() + ":u": "admin"}}) + h.llmTester = &stubLLMTester{} + ctx := webhookTenantAdminCtx(tid, "u") + w := doRequest(t, h, http.MethodPost, "/v1/webhooks/"+wh.ID.String()+"/test", map[string]any{"input": "x"}, ctx) + if w.Code != http.StatusConflict { + t.Fatalf("revoked webhook test must be 409, got %d", w.Code) + } +} diff --git a/internal/http/webhooks_admin_test.go b/internal/http/webhooks_admin_test.go index dcb92c25..b3839c89 100644 --- a/internal/http/webhooks_admin_test.go +++ b/internal/http/webhooks_admin_test.go @@ -222,7 +222,7 @@ func webhookTenantCtxWithRole(tenantID uuid.UUID, userID, role string) context.C const testAdminEncKey = "00000000000000000000000000000000" func newAdminHandler(ws *adminWebhookStore, ts *adminTenantStore) *WebhooksAdminHandler { - h := NewWebhooksAdminHandler(ws, ts, nil) + h := NewWebhooksAdminHandler(ws, nil, ts, nil) h.SetEncKey(testAdminEncKey) // required since K6 guard rejects empty encKey return h } diff --git a/internal/http/webhooks_llm.go b/internal/http/webhooks_llm.go index 86026dff..d76461bf 100644 --- a/internal/http/webhooks_llm.go +++ b/internal/http/webhooks_llm.go @@ -511,6 +511,137 @@ func (h *WebhookLLMHandler) handleAsync( }) } +// RunTest performs a synchronous test invocation of an llm-kind webhook on behalf of an +// admin (POST /v1/webhooks/{id}/test). The caller (WebhooksAdminHandler) has already +// authorized the request and verified webhook ownership — no webhook secret is involved. +// It resolves the bound agent, runs it within the standard sync timeout, writes an audit +// row (mode=sync), and returns the response. Errors are returned (not written to HTTP). +func (h *WebhookLLMHandler) RunTest(ctx context.Context, wh *store.WebhookData, input, model string) (*webhookLLMSyncResp, error) { + if wh == nil || wh.AgentID == nil { + return nil, errors.New("webhook has no bound agent") + } + agentID := wh.AgentID.String() + + ag, agErr := h.agentRouter.Get(ctx, agentID) + if agErr != nil { + return nil, fmt.Errorf("agent not found: %w", agErr) + } + // P0 cross-tenant isolation: agent must belong to webhook's tenant. + if ag.UUID() != *wh.AgentID { + slog.Warn("security.webhook.tenant_mismatch", "webhook_id", wh.ID, "webhook_tenant", wh.TenantID, "agent_id", agentID) + return nil, errors.New("agent tenant mismatch") + } + + callID := store.GenNewID() + deliveryID := store.GenNewID() + now := time.Now() + runID := uuid.NewString() + sessionKey := resolveWebhookSessionKey("", agentID, wh.ID, runID) + + requestPayload, _ := buildAuditPayload(nil, map[string]any{"test": true, "input_len": len(input)}) + callRecord := &store.WebhookCallData{ + ID: callID, + TenantID: wh.TenantID, + WebhookID: wh.ID, + AgentID: wh.AgentID, + DeliveryID: deliveryID, + Mode: "sync", + Status: "running", + RequestPayload: requestPayload, + CreatedAt: now, + StartedAt: &now, + } + + rr := agent.RunRequest{ + SessionKey: sessionKey, + Message: input, + Channel: "webhook", + ChatID: wh.ID.String(), + RunID: runID, + Stream: false, + ModelOverride: model, + TraceName: "webhook.llm.test", + TraceTags: []string{"webhook", "test"}, + } + + timeout := webhookLLMTimeout + if h.syncTimeout > 0 { + timeout = h.syncTimeout + } + + type runOutcome struct { + result *agent.RunResult + err error + } + outCh := make(chan runOutcome, 1) + + laneCtx, laneCancel := context.WithTimeout(ctx, timeout) + defer laneCancel() + + submitErr := h.lane.Submit(laneCtx, func() { + runCtx, runCancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) + defer runCancel() + result, err := ag.Run(runCtx, rr) + outCh <- runOutcome{result: result, err: err} + }) + if submitErr != nil { + completedAt := time.Now() + errMsg := submitErr.Error() + callRecord.Status = "failed" + callRecord.Attempts = 1 + callRecord.CompletedAt = &completedAt + callRecord.LastError = &errMsg + persistWebhookCall(ctx, h.callStore, callRecord, false, "webhook.llm.test_audit_write_failed") + return nil, fmt.Errorf("webhook lane saturated: %w", submitErr) + } + + var out runOutcome + select { + case out = <-outCh: + case <-laneCtx.Done(): + out = runOutcome{err: context.DeadlineExceeded} + } + + if out.err != nil { + completedAt := time.Now() + errMsg := out.err.Error() + callRecord.Status = "failed" + callRecord.Attempts = 1 + callRecord.CompletedAt = &completedAt + callRecord.LastError = &errMsg + persistWebhookCall(ctx, h.callStore, callRecord, false, "webhook.llm.test_audit_write_failed") + return nil, out.err + } + + resp := &webhookLLMSyncResp{ + CallID: callID.String(), + AgentID: agentID, + Output: out.result.Content, + FinishReason: "stop", + } + if out.result.Usage != nil { + resp.Usage = &webhookLLMUsage{ + PromptTokens: out.result.Usage.PromptTokens, + CompletionTokens: out.result.Usage.CompletionTokens, + TotalTokens: out.result.Usage.TotalTokens, + } + } + + respBytes, _ := json.Marshal(resp) + if len(respBytes) > webhookLLMResponseTruncate { + respBytes = respBytes[:webhookLLMResponseTruncate] + } + completedAt := time.Now() + callRecord.Status = "done" + callRecord.Attempts = 1 + callRecord.Response = respBytes + callRecord.CompletedAt = &completedAt + persistWebhookCall(ctx, h.callStore, callRecord, false, "webhook.llm.test_audit_write_failed") + + slog.Info("webhook.llm.test", "call_id", callID, "agent_id", agentID, "webhook_id", wh.ID, "output_len", len(out.result.Content)) + return resp, nil +} + // buildInput parses the raw JSON input into a user message and optional extra system prompt. // // Two formats are accepted: diff --git a/internal/http/webhooks_message.go b/internal/http/webhooks_message.go index 130b8f02..8b076027 100644 --- a/internal/http/webhooks_message.go +++ b/internal/http/webhooks_message.go @@ -203,6 +203,129 @@ func (h *WebhookMessageHandler) handle(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, respBody) } +// RunTest performs a test message send for a message-kind webhook on behalf of an admin +// (POST /v1/webhooks/{id}/test). The caller has already authorized + verified ownership. +// It resolves + tenant-validates the channel, sends the message (real side effect), writes +// an audit row (mode=sync), and returns the response. Errors are returned (not written to HTTP). +func (h *WebhookMessageHandler) RunTest(ctx context.Context, wh *store.WebhookData, req webhookMessageReq) (*webhookMessageResp, error) { + if wh == nil { + return nil, errors.New("webhook missing") + } + + // Resolve channel: bound channel_id takes precedence over request channel_name. + var channelName string + if wh.ChannelID != nil { + inst, err := h.channelInstances.Get(ctx, *wh.ChannelID) + if err != nil || inst == nil { + return nil, errors.New("bound channel not found") + } + channelName = inst.Name + } else { + if req.ChannelName == "" { + return nil, errors.New("channel_name required") + } + channelName = req.ChannelName + } + + // P0 cross-tenant isolation: channel must belong to webhook's tenant. + channelTenantID, exists := h.channelMgr.ChannelTenantID(channelName) + if !exists { + return nil, errors.New("channel not found") + } + if channelTenantID != uuid.Nil && channelTenantID != wh.TenantID { + slog.Warn("security.webhook.tenant_leak_attempt", "webhook_id", wh.ID, "webhook_tenant", wh.TenantID, + "channel_name", channelName, "channel_tenant", channelTenantID) + return nil, errors.New("channel tenant mismatch") + } + + if req.ChatID == "" { + return nil, errors.New("chat_id required") + } + if req.Content == "" && req.MediaURL == "" { + return nil, errors.New("content or media_url required") + } + if len(req.Content) > webhookContentMaxBytes { + return nil, errors.New("content exceeds 16 KB limit") + } + + callID := store.GenNewID() + deliveryID := store.GenNewID() + now := time.Now() + requestPayload, _ := buildAuditPayload(nil, map[string]any{ + "test": true, "channel_name": channelName, "chat_id": req.ChatID, "has_media": req.MediaURL != "", + }) + callRecord := &store.WebhookCallData{ + ID: callID, + TenantID: wh.TenantID, + WebhookID: wh.ID, + AgentID: wh.AgentID, + DeliveryID: deliveryID, + Mode: "sync", + Status: "running", + StartedAt: &now, + RequestPayload: requestPayload, + CreatedAt: now, + } + + warning, sendErr := h.dispatchTest(ctx, wh, req, channelName) + if sendErr != nil { + h.failCall(ctx, callRecord, false, sendErr.Error()) + return nil, sendErr + } + + completedAt := time.Now() + callRecord.Status = "done" + callRecord.CompletedAt = &completedAt + callRecord.Attempts = 1 + resp := &webhookMessageResp{ + CallID: callID.String(), + Status: "sent", + ChannelName: channelName, + ChatID: req.ChatID, + Warning: warning, + } + respBytes, _ := json.Marshal(resp) + callRecord.Response = respBytes + persistWebhookCall(ctx, h.callStore, callRecord, false, "webhook.message.test_audit_write_failed") + + slog.Info("webhook.message.test", "tenant_id", wh.TenantID, "webhook_id", wh.ID, + "channel", channelName, "chat_id", req.ChatID, "has_media", req.MediaURL != "") + return resp, nil +} + +// dispatchTest sends a test message (text or media) and returns (warning, error). +// Mirrors dispatch but returns errors instead of writing HTTP responses. +func (h *WebhookMessageHandler) dispatchTest(ctx context.Context, wh *store.WebhookData, req webhookMessageReq, channelName string) (string, error) { + if req.MediaURL == "" { + if err := h.channelMgr.SendToChannel(ctx, channelName, req.ChatID, req.Content); err != nil { + return "", err + } + return "", nil + } + + probe, probeErr := probeMediaURL(req.MediaURL) + if probeErr != nil { + return "", probeErr + } + + channelType := h.channelMgr.ChannelTypeForName(channelName) + if channels.IsMediaCapable(channelType) { + media := []bus.MediaAttachment{{URL: req.MediaURL, ContentType: probe.ContentType, Caption: req.MediaCaption}} + if err := h.channelMgr.SendMediaToChannel(ctx, channelName, req.ChatID, req.Content, media); err != nil { + return "", err + } + return "", nil + } + + if req.FallbackToText { + if err := h.channelMgr.SendToChannel(ctx, channelName, req.ChatID, req.Content); err != nil { + return "", err + } + return "media_not_supported_fallback_text", nil + } + return "", errors.New("channel does not support media and fallback_to_text is false") +} + // dispatch sends the message (media or text) to the channel. // Returns (warning string, error). On non-nil error the response was already written. func (h *WebhookMessageHandler) dispatch( diff --git a/internal/i18n/catalog_en.go b/internal/i18n/catalog_en.go index aaff377e..4b23d749 100644 --- a/internal/i18n/catalog_en.go +++ b/internal/i18n/catalog_en.go @@ -267,28 +267,29 @@ func init() { MsgBackendNotReady: "workstation backend not ready: %s", // Webhooks - MsgWebhookAuthFailed: "webhook authentication failed", - MsgWebhookHMACInvalid: "HMAC signature is invalid", - MsgWebhookHMACTimestampSkew: "request timestamp outside acceptable window", - MsgWebhookBearerRequiredHMAC: "this webhook requires HMAC authentication", - MsgWebhookRevoked: "webhook has been revoked", - MsgWebhookKindMismatch: "request kind does not match webhook configuration", - MsgWebhookRateLimited: "webhook rate limit exceeded", - MsgWebhookBodyTooLarge: "request body exceeds size limit", - MsgWebhookIdempotencyConflict: "idempotency key conflict: request body mismatch", - MsgWebhookTenantMismatch: "webhook tenant mismatch", - MsgWebhookAgentNotFound: "webhook agent not found", - MsgWebhookChannelNotFound: "webhook channel not found", - MsgWebhookMediaSSRFBlocked: "media URL blocked by SSRF policy", - MsgWebhookMediaTooLarge: "media file exceeds size limit", - MsgWebhookMediaMIMEDenied: "media MIME type is not allowed", - MsgWebhookCallbackURLInvalid: "callback URL is invalid or blocked", - MsgWebhookLLMTimeout: "LLM processing timed out", - MsgWebhookLaneSaturated: "webhook processing lane is at capacity", - MsgWebhookLocalhostOnlyViolation: "this webhook is restricted to localhost callers", - MsgWebhookMediaChannelUnsupported: "channel does not support media attachments", - MsgWebhookIPDenied: "request origin is not in the IP allowlist", - MsgWebhookEncryptionUnavailable: "webhook encryption key not configured; set GOCLAW_ENCRYPTION_KEY to enable webhooks", + MsgWebhookAuthFailed: "webhook authentication failed", + MsgWebhookHMACInvalid: "HMAC signature is invalid", + MsgWebhookHMACTimestampSkew: "request timestamp outside acceptable window", + MsgWebhookBearerRequiredHMAC: "this webhook requires HMAC authentication", + MsgWebhookRevoked: "webhook has been revoked", + MsgWebhookKindMismatch: "request kind does not match webhook configuration", + MsgWebhookRateLimited: "webhook rate limit exceeded", + MsgWebhookBodyTooLarge: "request body exceeds size limit", + MsgWebhookIdempotencyConflict: "idempotency key conflict: request body mismatch", + MsgWebhookTenantMismatch: "webhook tenant mismatch", + MsgWebhookAgentNotFound: "webhook agent not found", + MsgWebhookChannelNotFound: "webhook channel not found", + MsgWebhookMediaSSRFBlocked: "media URL blocked by SSRF policy", + MsgWebhookMediaTooLarge: "media file exceeds size limit", + MsgWebhookMediaMIMEDenied: "media MIME type is not allowed", + MsgWebhookCallbackURLInvalid: "callback URL is invalid or blocked", + MsgWebhookLLMTimeout: "LLM processing timed out", + MsgWebhookLaneSaturated: "webhook processing lane is at capacity", + MsgWebhookLocalhostOnlyViolation: "this webhook is restricted to localhost callers", + MsgWebhookMediaChannelUnsupported: "channel does not support media attachments", + MsgWebhookIPDenied: "request origin is not in the IP allowlist", + MsgWebhookEncryptionUnavailable: "webhook encryption key not configured; set GOCLAW_ENCRYPTION_KEY to enable webhooks", + MsgWebhookMessageTestRequiresStandard: "testing message webhooks requires Standard edition", // Hooks MsgHookInvalidMatcher: "invalid matcher regex: %s", diff --git a/internal/i18n/catalog_vi.go b/internal/i18n/catalog_vi.go index a29f9a8c..64297589 100644 --- a/internal/i18n/catalog_vi.go +++ b/internal/i18n/catalog_vi.go @@ -257,28 +257,29 @@ func init() { MsgVoiceMessageFallback: "[Tin nhắn thoại]", // Webhooks - MsgWebhookAuthFailed: "xác thực webhook thất bại", - MsgWebhookHMACInvalid: "chữ ký HMAC không hợp lệ", - MsgWebhookHMACTimestampSkew: "thời gian yêu cầu nằm ngoài cửa sổ chấp nhận", - MsgWebhookBearerRequiredHMAC: "webhook này yêu cầu xác thực HMAC", - MsgWebhookRevoked: "webhook đã bị thu hồi", - MsgWebhookKindMismatch: "loại yêu cầu không khớp cấu hình webhook", - MsgWebhookRateLimited: "vượt quá giới hạn tốc độ webhook", - MsgWebhookBodyTooLarge: "nội dung yêu cầu vượt quá giới hạn kích thước", - MsgWebhookIdempotencyConflict: "xung đột idempotency key: nội dung yêu cầu không khớp", - MsgWebhookTenantMismatch: "tenant của webhook không khớp", - MsgWebhookAgentNotFound: "không tìm thấy agent webhook", - MsgWebhookChannelNotFound: "không tìm thấy kênh webhook", - MsgWebhookMediaSSRFBlocked: "URL media bị chặn bởi chính sách SSRF", - MsgWebhookMediaTooLarge: "tệp media vượt quá giới hạn kích thước", - MsgWebhookMediaMIMEDenied: "loại MIME của media không được phép", - MsgWebhookCallbackURLInvalid: "URL callback không hợp lệ hoặc bị chặn", - MsgWebhookLLMTimeout: "LLM xử lý hết thời gian chờ", - MsgWebhookLaneSaturated: "làn xử lý webhook đã đầy", - MsgWebhookLocalhostOnlyViolation: "webhook này chỉ cho phép gọi từ localhost", - MsgWebhookMediaChannelUnsupported: "kênh không hỗ trợ tệp đính kèm media", - MsgWebhookIPDenied: "địa chỉ IP không nằm trong danh sách cho phép", - MsgWebhookEncryptionUnavailable: "khóa mã hóa webhook chưa được cấu hình; hãy đặt GOCLAW_ENCRYPTION_KEY để kích hoạt webhook", + MsgWebhookAuthFailed: "xác thực webhook thất bại", + MsgWebhookHMACInvalid: "chữ ký HMAC không hợp lệ", + MsgWebhookHMACTimestampSkew: "thời gian yêu cầu nằm ngoài cửa sổ chấp nhận", + MsgWebhookBearerRequiredHMAC: "webhook này yêu cầu xác thực HMAC", + MsgWebhookRevoked: "webhook đã bị thu hồi", + MsgWebhookKindMismatch: "loại yêu cầu không khớp cấu hình webhook", + MsgWebhookRateLimited: "vượt quá giới hạn tốc độ webhook", + MsgWebhookBodyTooLarge: "nội dung yêu cầu vượt quá giới hạn kích thước", + MsgWebhookIdempotencyConflict: "xung đột idempotency key: nội dung yêu cầu không khớp", + MsgWebhookTenantMismatch: "tenant của webhook không khớp", + MsgWebhookAgentNotFound: "không tìm thấy agent webhook", + MsgWebhookChannelNotFound: "không tìm thấy kênh webhook", + MsgWebhookMediaSSRFBlocked: "URL media bị chặn bởi chính sách SSRF", + MsgWebhookMediaTooLarge: "tệp media vượt quá giới hạn kích thước", + MsgWebhookMediaMIMEDenied: "loại MIME của media không được phép", + MsgWebhookCallbackURLInvalid: "URL callback không hợp lệ hoặc bị chặn", + MsgWebhookLLMTimeout: "LLM xử lý hết thời gian chờ", + MsgWebhookLaneSaturated: "làn xử lý webhook đã đầy", + MsgWebhookLocalhostOnlyViolation: "webhook này chỉ cho phép gọi từ localhost", + MsgWebhookMediaChannelUnsupported: "kênh không hỗ trợ tệp đính kèm media", + MsgWebhookIPDenied: "địa chỉ IP không nằm trong danh sách cho phép", + MsgWebhookEncryptionUnavailable: "khóa mã hóa webhook chưa được cấu hình; hãy đặt GOCLAW_ENCRYPTION_KEY để kích hoạt webhook", + MsgWebhookMessageTestRequiresStandard: "kiểm thử webhook loại message yêu cầu bản Standard", // Hooks // Workstation diff --git a/internal/i18n/catalog_zh.go b/internal/i18n/catalog_zh.go index 0a88c95e..8c4104bb 100644 --- a/internal/i18n/catalog_zh.go +++ b/internal/i18n/catalog_zh.go @@ -257,28 +257,29 @@ func init() { MsgVoiceMessageFallback: "[语音消息]", // Webhooks - MsgWebhookAuthFailed: "Webhook 身份验证失败", - MsgWebhookHMACInvalid: "HMAC 签名无效", - MsgWebhookHMACTimestampSkew: "请求时间戳超出可接受窗口", - MsgWebhookBearerRequiredHMAC: "此 Webhook 需要 HMAC 身份验证", - MsgWebhookRevoked: "Webhook 已被撤销", - MsgWebhookKindMismatch: "请求类型与 Webhook 配置不匹配", - MsgWebhookRateLimited: "超出 Webhook 速率限制", - MsgWebhookBodyTooLarge: "请求正文超出大小限制", - MsgWebhookIdempotencyConflict: "幂等键冲突:请求正文不匹配", - MsgWebhookTenantMismatch: "Webhook 租户不匹配", - MsgWebhookAgentNotFound: "未找到 Webhook 代理", - MsgWebhookChannelNotFound: "未找到 Webhook 频道", - MsgWebhookMediaSSRFBlocked: "媒体 URL 被 SSRF 策略拦截", - MsgWebhookMediaTooLarge: "媒体文件超出大小限制", - MsgWebhookMediaMIMEDenied: "媒体 MIME 类型不被允许", - MsgWebhookCallbackURLInvalid: "回调 URL 无效或被拦截", - MsgWebhookLLMTimeout: "LLM 处理超时", - MsgWebhookLaneSaturated: "Webhook 处理通道已满", - MsgWebhookLocalhostOnlyViolation: "此 Webhook 仅限本地调用", - MsgWebhookMediaChannelUnsupported: "频道不支持媒体附件", - MsgWebhookIPDenied: "请求来源不在 IP 白名单中", - MsgWebhookEncryptionUnavailable: "Webhook 加密密钥未配置;请设置 GOCLAW_ENCRYPTION_KEY 以启用 Webhook", + MsgWebhookAuthFailed: "Webhook 身份验证失败", + MsgWebhookHMACInvalid: "HMAC 签名无效", + MsgWebhookHMACTimestampSkew: "请求时间戳超出可接受窗口", + MsgWebhookBearerRequiredHMAC: "此 Webhook 需要 HMAC 身份验证", + MsgWebhookRevoked: "Webhook 已被撤销", + MsgWebhookKindMismatch: "请求类型与 Webhook 配置不匹配", + MsgWebhookRateLimited: "超出 Webhook 速率限制", + MsgWebhookBodyTooLarge: "请求正文超出大小限制", + MsgWebhookIdempotencyConflict: "幂等键冲突:请求正文不匹配", + MsgWebhookTenantMismatch: "Webhook 租户不匹配", + MsgWebhookAgentNotFound: "未找到 Webhook 代理", + MsgWebhookChannelNotFound: "未找到 Webhook 频道", + MsgWebhookMediaSSRFBlocked: "媒体 URL 被 SSRF 策略拦截", + MsgWebhookMediaTooLarge: "媒体文件超出大小限制", + MsgWebhookMediaMIMEDenied: "媒体 MIME 类型不被允许", + MsgWebhookCallbackURLInvalid: "回调 URL 无效或被拦截", + MsgWebhookLLMTimeout: "LLM 处理超时", + MsgWebhookLaneSaturated: "Webhook 处理通道已满", + MsgWebhookLocalhostOnlyViolation: "此 Webhook 仅限本地调用", + MsgWebhookMediaChannelUnsupported: "频道不支持媒体附件", + MsgWebhookIPDenied: "请求来源不在 IP 白名单中", + MsgWebhookEncryptionUnavailable: "Webhook 加密密钥未配置;请设置 GOCLAW_ENCRYPTION_KEY 以启用 Webhook", + MsgWebhookMessageTestRequiresStandard: "测试 message 类型 Webhook 需要 Standard 版本", // Hooks // Workstation diff --git a/internal/i18n/keys.go b/internal/i18n/keys.go index 2dc8083a..2a3fe08e 100644 --- a/internal/i18n/keys.go +++ b/internal/i18n/keys.go @@ -300,28 +300,29 @@ const ( MsgTenantScopeRequired = "error.tenant_scope_required" // "tenant scope is required for this operation" // --- Webhooks --- - MsgWebhookAuthFailed = "webhook.auth_failed" // "webhook authentication failed" - MsgWebhookHMACInvalid = "webhook.hmac_invalid" // "HMAC signature is invalid" - MsgWebhookHMACTimestampSkew = "webhook.hmac_timestamp_skew" // "request timestamp outside acceptable window" - MsgWebhookBearerRequiredHMAC = "webhook.bearer_required_hmac" // "this webhook requires HMAC authentication" - MsgWebhookRevoked = "webhook.revoked" // "webhook has been revoked" - MsgWebhookKindMismatch = "webhook.kind_mismatch" // "request kind does not match webhook configuration" - MsgWebhookRateLimited = "webhook.rate_limited" // "webhook rate limit exceeded" - MsgWebhookBodyTooLarge = "webhook.body_too_large" // "request body exceeds size limit" - MsgWebhookIdempotencyConflict = "webhook.idempotency_conflict" // "idempotency key conflict: request body mismatch" - MsgWebhookTenantMismatch = "webhook.tenant_mismatch" // "webhook tenant mismatch" - MsgWebhookAgentNotFound = "webhook.agent_not_found" // "webhook agent not found" - MsgWebhookChannelNotFound = "webhook.channel_not_found" // "webhook channel not found" - MsgWebhookMediaSSRFBlocked = "webhook.media_ssrf_blocked" // "media URL blocked by SSRF policy" - MsgWebhookMediaTooLarge = "webhook.media_too_large" // "media file exceeds size limit" - MsgWebhookMediaMIMEDenied = "webhook.media_mime_denied" // "media MIME type is not allowed" - MsgWebhookCallbackURLInvalid = "webhook.callback_url_invalid" // "callback URL is invalid or blocked" - MsgWebhookLLMTimeout = "webhook.llm_timeout" // "LLM processing timed out" - MsgWebhookLaneSaturated = "webhook.lane_saturated" // "webhook processing lane is at capacity" - MsgWebhookLocalhostOnlyViolation = "webhook.localhost_only_violation" // "this webhook is restricted to localhost callers" - MsgWebhookMediaChannelUnsupported = "webhook.media_channel_unsupported" // "channel does not support media attachments" - MsgWebhookIPDenied = "webhook.ip_denied" // "request origin is not in the IP allowlist" - MsgWebhookEncryptionUnavailable = "webhook.encryption_unavailable" // "webhook encryption key not configured; set GOCLAW_ENCRYPTION_KEY to enable webhooks" + MsgWebhookAuthFailed = "webhook.auth_failed" // "webhook authentication failed" + MsgWebhookHMACInvalid = "webhook.hmac_invalid" // "HMAC signature is invalid" + MsgWebhookHMACTimestampSkew = "webhook.hmac_timestamp_skew" // "request timestamp outside acceptable window" + MsgWebhookBearerRequiredHMAC = "webhook.bearer_required_hmac" // "this webhook requires HMAC authentication" + MsgWebhookRevoked = "webhook.revoked" // "webhook has been revoked" + MsgWebhookKindMismatch = "webhook.kind_mismatch" // "request kind does not match webhook configuration" + MsgWebhookRateLimited = "webhook.rate_limited" // "webhook rate limit exceeded" + MsgWebhookBodyTooLarge = "webhook.body_too_large" // "request body exceeds size limit" + MsgWebhookIdempotencyConflict = "webhook.idempotency_conflict" // "idempotency key conflict: request body mismatch" + MsgWebhookTenantMismatch = "webhook.tenant_mismatch" // "webhook tenant mismatch" + MsgWebhookAgentNotFound = "webhook.agent_not_found" // "webhook agent not found" + MsgWebhookChannelNotFound = "webhook.channel_not_found" // "webhook channel not found" + MsgWebhookMediaSSRFBlocked = "webhook.media_ssrf_blocked" // "media URL blocked by SSRF policy" + MsgWebhookMediaTooLarge = "webhook.media_too_large" // "media file exceeds size limit" + MsgWebhookMediaMIMEDenied = "webhook.media_mime_denied" // "media MIME type is not allowed" + MsgWebhookCallbackURLInvalid = "webhook.callback_url_invalid" // "callback URL is invalid or blocked" + MsgWebhookLLMTimeout = "webhook.llm_timeout" // "LLM processing timed out" + MsgWebhookLaneSaturated = "webhook.lane_saturated" // "webhook processing lane is at capacity" + MsgWebhookLocalhostOnlyViolation = "webhook.localhost_only_violation" // "this webhook is restricted to localhost callers" + MsgWebhookMediaChannelUnsupported = "webhook.media_channel_unsupported" // "channel does not support media attachments" + MsgWebhookIPDenied = "webhook.ip_denied" // "request origin is not in the IP allowlist" + MsgWebhookEncryptionUnavailable = "webhook.encryption_unavailable" // "webhook encryption key not configured; set GOCLAW_ENCRYPTION_KEY to enable webhooks" + MsgWebhookMessageTestRequiresStandard = "webhook.message_test_requires_standard" // "testing message webhooks requires Standard edition" // --- Workstation permissions --- MsgWorkstationCmdDenied = "error.workstation_cmd_denied" // "command denied by workstation policy: %s" diff --git a/ui/web/src/components/layout/sidebar.tsx b/ui/web/src/components/layout/sidebar.tsx index 4a61f330..3a196df0 100644 --- a/ui/web/src/components/layout/sidebar.tsx +++ b/ui/web/src/components/layout/sidebar.tsx @@ -30,6 +30,7 @@ import { FileArchive, DatabaseBackup, Webhook, + Cable, MonitorCog, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -99,6 +100,9 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) { + {isAdmin && ( + + )} {isAdmin && ( diff --git a/ui/web/src/i18n/index.ts b/ui/web/src/i18n/index.ts index 0f031fc1..50ddcaf2 100644 --- a/ui/web/src/i18n/index.ts +++ b/ui/web/src/i18n/index.ts @@ -41,6 +41,7 @@ import enImportExport from "./locales/en/import-export.json"; import enV3Capabilities from "./locales/en/v3-capabilities.json"; import enBackup from "./locales/en/backup.json"; import enHooks from "./locales/en/hooks.json"; +import enWebhooks from "./locales/en/webhooks.json"; import enWorkstations from "./locales/en/workstations.json"; // --- VI namespaces --- @@ -83,6 +84,7 @@ import viImportExport from "./locales/vi/import-export.json"; import viV3Capabilities from "./locales/vi/v3-capabilities.json"; import viBackup from "./locales/vi/backup.json"; import viHooks from "./locales/vi/hooks.json"; +import viWebhooks from "./locales/vi/webhooks.json"; import viWorkstations from "./locales/vi/workstations.json"; // --- ZH namespaces --- @@ -125,6 +127,7 @@ import zhImportExport from "./locales/zh/import-export.json"; import zhV3Capabilities from "./locales/zh/v3-capabilities.json"; import zhBackup from "./locales/zh/backup.json"; import zhHooks from "./locales/zh/hooks.json"; +import zhWebhooks from "./locales/zh/webhooks.json"; import zhWorkstations from "./locales/zh/workstations.json"; const STORAGE_KEY = "goclaw:language"; @@ -148,6 +151,7 @@ const ns = [ "v3-capabilities", "backup", "hooks", + "webhooks", "workstations", ] as const; @@ -171,6 +175,7 @@ i18n.use(initReactI18next).init({ "v3-capabilities": enV3Capabilities, backup: enBackup, hooks: enHooks, + webhooks: enWebhooks, workstations: enWorkstations, }, vi: { @@ -191,6 +196,7 @@ i18n.use(initReactI18next).init({ "v3-capabilities": viV3Capabilities, backup: viBackup, hooks: viHooks, + webhooks: viWebhooks, workstations: viWorkstations, }, zh: { @@ -211,6 +217,7 @@ i18n.use(initReactI18next).init({ "v3-capabilities": zhV3Capabilities, backup: zhBackup, hooks: zhHooks, + webhooks: zhWebhooks, workstations: zhWorkstations, }, }, diff --git a/ui/web/src/i18n/locales/en/sidebar.json b/ui/web/src/i18n/locales/en/sidebar.json index 98b6848d..0ad2e85d 100644 --- a/ui/web/src/i18n/locales/en/sidebar.json +++ b/ui/web/src/i18n/locales/en/sidebar.json @@ -16,6 +16,7 @@ "sessions": "Sessions", "pendingMessages": "Pending Messages", "channels": "Channels", + "webhooks": "Webhooks", "contacts": "Contacts", "skills": "Skills", "cron": "Cron", diff --git a/ui/web/src/i18n/locales/en/webhooks.json b/ui/web/src/i18n/locales/en/webhooks.json new file mode 100644 index 00000000..4cf42dfe --- /dev/null +++ b/ui/web/src/i18n/locales/en/webhooks.json @@ -0,0 +1,151 @@ +{ + "title": "Webhooks", + "description": "Inbound HTTP webhooks that trigger an agent (LLM) or send a channel message.", + "addWebhook": "Create Webhook", + "searchPlaceholder": "Search webhooks...", + "showRevoked": "Show revoked", + "emptyTitle": "No webhooks", + "emptyDescription": "Create your first webhook to expose an agent or channel over HTTP.", + "neverUsed": "Never used", + "kind": { + "llm": "LLM", + "message": "Message" + }, + "status": { + "active": "Active", + "revoked": "Revoked" + }, + "columns": { + "name": "Name", + "webhookId": "Webhook ID", + "kind": "Kind", + "target": "Target", + "status": "Status", + "lastUsed": "Last used", + "actions": "Actions" + }, + "actions": { + "test": "Test", + "calls": "Delivery history", + "edit": "Edit", + "rotate": "Rotate secret", + "revoke": "Revoke" + }, + "form": { + "createTitle": "Create Webhook", + "editTitle": "Edit Webhook", + "description": "Configure how this webhook authenticates and what it triggers.", + "name": "Name", + "namePlaceholder": "e.g. CRM lead handler", + "kind": "Kind", + "messageStandardOnly": "Standard edition only", + "agent": "Agent", + "agentPlaceholder": "Select an agent", + "agentHint": "Required to invoke and test an LLM webhook.", + "none": "None", + "channel": "Channel", + "channelPlaceholder": "Select a channel", + "channelAny": "Any (specify per request)", + "rateLimit": "Rate limit (requests / minute)", + "ipAllowlist": "IP allowlist", + "ipAllowlistPlaceholder": "e.g. 203.0.113.0/24, 198.51.100.7", + "ipAllowlistHint": "Comma-separated IPs or CIDRs. Empty allows all.", + "requireHmac": "Require HMAC", + "requireHmacHint": "Reject bearer-token auth; require signed requests only.", + "localhostOnly": "Localhost only", + "localhostOnlyHint": "Only accept requests from 127.0.0.1 / ::1.", + "localhostOnlyLite": "Forced on in Lite edition.", + "cancel": "Cancel", + "create": "Create", + "save": "Save", + "saving": "Saving..." + }, + "secret": { + "title": "Webhook secret", + "description": "Copy these values now — they are shown only once.", + "warning": "This secret will not be shown again. Store it securely.", + "webhookId": "Webhook ID (X-Webhook-Id header)", + "bearer": "Secret (Bearer token)", + "hmacKey": "HMAC signing key", + "copy": "Copy", + "copied": "Copied", + "usageHint": "Send the secret as 'Authorization: Bearer ', or sign requests with the HMAC key.", + "done": "Done" + }, + "test": { + "title": "Test webhook: {{name}}", + "description": "Invoke this webhook server-side using your admin session (no secret needed).", + "messageWarning": "This sends a real message to the target chat.", + "channelName": "Channel name", + "channelNamePlaceholder": "telegram-main", + "input": "Input", + "inputPlaceholder": "Enter a prompt to send to the agent", + "chatId": "Chat ID", + "chatIdPlaceholder": "Recipient chat identifier", + "content": "Content", + "result": "Result", + "sent": "Sent to {{channel}} (chat {{chatId}})", + "run": "Send test", + "running": "Sending...", + "close": "Close", + "failed": "Test failed" + }, + "calls": { + "title": "Delivery history: {{name}}", + "description": "Recent invocations of this webhook.", + "statusAll": "All statuses", + "status": { + "queued": "Queued", + "running": "Running", + "done": "Done", + "failed": "Failed", + "dead": "Dead" + }, + "cols": { + "status": "Status", + "mode": "Mode", + "attempts": "Attempts", + "created": "Created", + "detail": "Detail" + }, + "emptyTitle": "No deliveries yet", + "emptyDescription": "Calls to this webhook will appear here.", + "noMore": "No more calls.", + "page": "Page {{page}}", + "prev": "Previous", + "next": "Next" + }, + "callDetail": { + "title": "Delivery detail", + "description": "Full record of a single webhook invocation.", + "loading": "Loading...", + "deliveryId": "Delivery ID", + "idempotencyKey": "Idempotency key", + "callbackUrl": "Callback URL", + "created": "Created", + "started": "Started", + "completed": "Completed", + "nextAttempt": "Next attempt", + "lastError": "Last error", + "requestPayload": "Request payload", + "response": "Response" + }, + "rotate": { + "title": "Rotate secret", + "description": "Rotating invalidates the current secret for \"{{name}}\". A new secret will be shown once." + }, + "revoke": { + "title": "Revoke webhook", + "description": "Revoking permanently disables \"{{name}}\". This cannot be undone." + }, + "toast": { + "created": "Webhook created", + "updated": "Webhook updated", + "secretRotated": "Secret rotated", + "revoked": "Webhook revoked", + "failedCreate": "Failed to create webhook", + "failedUpdate": "Failed to update webhook", + "failedRotate": "Failed to rotate secret", + "failedRevoke": "Failed to revoke webhook" + } +} diff --git a/ui/web/src/i18n/locales/vi/sidebar.json b/ui/web/src/i18n/locales/vi/sidebar.json index 12376030..1b4fedfe 100644 --- a/ui/web/src/i18n/locales/vi/sidebar.json +++ b/ui/web/src/i18n/locales/vi/sidebar.json @@ -16,6 +16,7 @@ "sessions": "Session", "pendingMessages": "Tin nhắn chờ", "channels": "Channel", + "webhooks": "Webhook", "contacts": "Liên hệ", "skills": "Skill", "cron": "Tác vụ định kỳ", diff --git a/ui/web/src/i18n/locales/vi/webhooks.json b/ui/web/src/i18n/locales/vi/webhooks.json new file mode 100644 index 00000000..899bd3cc --- /dev/null +++ b/ui/web/src/i18n/locales/vi/webhooks.json @@ -0,0 +1,151 @@ +{ + "title": "Webhook", + "description": "Webhook HTTP đến để gọi agent (LLM) hoặc gửi tin nhắn qua channel.", + "addWebhook": "Tạo Webhook", + "searchPlaceholder": "Tìm webhook...", + "showRevoked": "Hiện đã thu hồi", + "emptyTitle": "Chưa có webhook", + "emptyDescription": "Tạo webhook đầu tiên để mở agent hoặc channel qua HTTP.", + "neverUsed": "Chưa dùng", + "kind": { + "llm": "LLM", + "message": "Tin nhắn" + }, + "status": { + "active": "Hoạt động", + "revoked": "Đã thu hồi" + }, + "columns": { + "name": "Tên", + "webhookId": "Webhook ID", + "kind": "Loại", + "target": "Đích", + "status": "Trạng thái", + "lastUsed": "Dùng gần nhất", + "actions": "Thao tác" + }, + "actions": { + "test": "Kiểm thử", + "calls": "Lịch sử gọi", + "edit": "Sửa", + "rotate": "Xoay secret", + "revoke": "Thu hồi" + }, + "form": { + "createTitle": "Tạo Webhook", + "editTitle": "Sửa Webhook", + "description": "Cấu hình cách webhook xác thực và việc nó kích hoạt.", + "name": "Tên", + "namePlaceholder": "vd: Xử lý lead CRM", + "kind": "Loại", + "messageStandardOnly": "Chỉ bản Standard", + "agent": "Agent", + "agentPlaceholder": "Chọn agent", + "agentHint": "Bắt buộc để gọi và kiểm thử webhook LLM.", + "none": "Không", + "channel": "Channel", + "channelPlaceholder": "Chọn channel", + "channelAny": "Bất kỳ (chỉ định khi gọi)", + "rateLimit": "Giới hạn (request / phút)", + "ipAllowlist": "Danh sách IP cho phép", + "ipAllowlistPlaceholder": "vd: 203.0.113.0/24, 198.51.100.7", + "ipAllowlistHint": "IP hoặc CIDR, cách nhau bằng dấu phẩy. Để trống cho phép tất cả.", + "requireHmac": "Bắt buộc HMAC", + "requireHmacHint": "Từ chối xác thực bearer token; chỉ chấp nhận request đã ký.", + "localhostOnly": "Chỉ localhost", + "localhostOnlyHint": "Chỉ chấp nhận request từ 127.0.0.1 / ::1.", + "localhostOnlyLite": "Bị bật cố định ở bản Lite.", + "cancel": "Hủy", + "create": "Tạo", + "save": "Lưu", + "saving": "Đang lưu..." + }, + "secret": { + "title": "Secret của webhook", + "description": "Sao chép các giá trị này ngay — chỉ hiển thị một lần.", + "warning": "Secret này sẽ không hiển thị lại. Hãy lưu trữ an toàn.", + "webhookId": "Webhook ID (header X-Webhook-Id)", + "bearer": "Secret (Bearer token)", + "hmacKey": "Khóa ký HMAC", + "copy": "Sao chép", + "copied": "Đã chép", + "usageHint": "Gửi secret qua 'Authorization: Bearer ', hoặc ký request bằng khóa HMAC.", + "done": "Xong" + }, + "test": { + "title": "Kiểm thử webhook: {{name}}", + "description": "Gọi webhook phía máy chủ bằng phiên admin của bạn (không cần secret).", + "messageWarning": "Thao tác này gửi tin nhắn thật đến chat đích.", + "channelName": "Tên channel", + "channelNamePlaceholder": "telegram-main", + "input": "Đầu vào", + "inputPlaceholder": "Nhập prompt gửi cho agent", + "chatId": "Chat ID", + "chatIdPlaceholder": "Định danh chat người nhận", + "content": "Nội dung", + "result": "Kết quả", + "sent": "Đã gửi tới {{channel}} (chat {{chatId}})", + "run": "Gửi thử", + "running": "Đang gửi...", + "close": "Đóng", + "failed": "Kiểm thử thất bại" + }, + "calls": { + "title": "Lịch sử gọi: {{name}}", + "description": "Các lần gọi gần đây của webhook này.", + "statusAll": "Tất cả trạng thái", + "status": { + "queued": "Chờ", + "running": "Đang chạy", + "done": "Xong", + "failed": "Lỗi", + "dead": "Bỏ" + }, + "cols": { + "status": "Trạng thái", + "mode": "Chế độ", + "attempts": "Số lần", + "created": "Tạo lúc", + "detail": "Chi tiết" + }, + "emptyTitle": "Chưa có lần gọi nào", + "emptyDescription": "Các lần gọi webhook này sẽ hiển thị ở đây.", + "noMore": "Không còn lần gọi nào.", + "page": "Trang {{page}}", + "prev": "Trước", + "next": "Sau" + }, + "callDetail": { + "title": "Chi tiết lần gọi", + "description": "Bản ghi đầy đủ của một lần gọi webhook.", + "loading": "Đang tải...", + "deliveryId": "Delivery ID", + "idempotencyKey": "Idempotency key", + "callbackUrl": "Callback URL", + "created": "Tạo lúc", + "started": "Bắt đầu", + "completed": "Hoàn tất", + "nextAttempt": "Lần thử kế", + "lastError": "Lỗi gần nhất", + "requestPayload": "Payload request", + "response": "Phản hồi" + }, + "rotate": { + "title": "Xoay secret", + "description": "Xoay sẽ vô hiệu secret hiện tại của \"{{name}}\". Secret mới sẽ hiển thị một lần." + }, + "revoke": { + "title": "Thu hồi webhook", + "description": "Thu hồi sẽ vô hiệu vĩnh viễn \"{{name}}\". Không thể hoàn tác." + }, + "toast": { + "created": "Đã tạo webhook", + "updated": "Đã cập nhật webhook", + "secretRotated": "Đã xoay secret", + "revoked": "Đã thu hồi webhook", + "failedCreate": "Tạo webhook thất bại", + "failedUpdate": "Cập nhật webhook thất bại", + "failedRotate": "Xoay secret thất bại", + "failedRevoke": "Thu hồi webhook thất bại" + } +} diff --git a/ui/web/src/i18n/locales/zh/sidebar.json b/ui/web/src/i18n/locales/zh/sidebar.json index ccf70ca9..c290fbac 100644 --- a/ui/web/src/i18n/locales/zh/sidebar.json +++ b/ui/web/src/i18n/locales/zh/sidebar.json @@ -15,6 +15,7 @@ "importExport": "Import & Export", "builtinTools": "内置工具", "channels": "Channel", + "webhooks": "Webhook", "contacts": "联系人", "chat": "对话", "config": "配置", diff --git a/ui/web/src/i18n/locales/zh/webhooks.json b/ui/web/src/i18n/locales/zh/webhooks.json new file mode 100644 index 00000000..4c7effc8 --- /dev/null +++ b/ui/web/src/i18n/locales/zh/webhooks.json @@ -0,0 +1,151 @@ +{ + "title": "Webhook", + "description": "用于触发智能体(LLM)或发送频道消息的入站 HTTP Webhook。", + "addWebhook": "创建 Webhook", + "searchPlaceholder": "搜索 Webhook...", + "showRevoked": "显示已吊销", + "emptyTitle": "暂无 Webhook", + "emptyDescription": "创建第一个 Webhook,通过 HTTP 暴露智能体或频道。", + "neverUsed": "从未使用", + "kind": { + "llm": "LLM", + "message": "消息" + }, + "status": { + "active": "启用", + "revoked": "已吊销" + }, + "columns": { + "name": "名称", + "webhookId": "Webhook ID", + "kind": "类型", + "target": "目标", + "status": "状态", + "lastUsed": "最近使用", + "actions": "操作" + }, + "actions": { + "test": "测试", + "calls": "调用历史", + "edit": "编辑", + "rotate": "轮换密钥", + "revoke": "吊销" + }, + "form": { + "createTitle": "创建 Webhook", + "editTitle": "编辑 Webhook", + "description": "配置此 Webhook 的鉴权方式及触发内容。", + "name": "名称", + "namePlaceholder": "例如:CRM 线索处理", + "kind": "类型", + "messageStandardOnly": "仅 Standard 版本", + "agent": "智能体", + "agentPlaceholder": "选择智能体", + "agentHint": "调用和测试 LLM Webhook 时必填。", + "none": "无", + "channel": "频道", + "channelPlaceholder": "选择频道", + "channelAny": "任意(按请求指定)", + "rateLimit": "速率限制(请求 / 分钟)", + "ipAllowlist": "IP 白名单", + "ipAllowlistPlaceholder": "例如:203.0.113.0/24, 198.51.100.7", + "ipAllowlistHint": "以逗号分隔的 IP 或 CIDR。留空则允许全部。", + "requireHmac": "强制 HMAC", + "requireHmacHint": "拒绝 Bearer 令牌鉴权,仅接受签名请求。", + "localhostOnly": "仅本地", + "localhostOnlyHint": "仅接受来自 127.0.0.1 / ::1 的请求。", + "localhostOnlyLite": "Lite 版本中强制开启。", + "cancel": "取消", + "create": "创建", + "save": "保存", + "saving": "保存中..." + }, + "secret": { + "title": "Webhook 密钥", + "description": "请立即复制这些值——它们仅显示一次。", + "warning": "此密钥不会再次显示,请妥善保存。", + "webhookId": "Webhook ID(X-Webhook-Id 请求头)", + "bearer": "密钥(Bearer 令牌)", + "hmacKey": "HMAC 签名密钥", + "copy": "复制", + "copied": "已复制", + "usageHint": "通过 'Authorization: Bearer ' 发送密钥,或使用 HMAC 密钥签名请求。", + "done": "完成" + }, + "test": { + "title": "测试 Webhook:{{name}}", + "description": "使用您的管理员会话在服务端调用此 Webhook(无需密钥)。", + "messageWarning": "此操作会向目标聊天发送真实消息。", + "channelName": "频道名称", + "channelNamePlaceholder": "telegram-main", + "input": "输入", + "inputPlaceholder": "输入发送给智能体的提示词", + "chatId": "聊天 ID", + "chatIdPlaceholder": "接收方聊天标识", + "content": "内容", + "result": "结果", + "sent": "已发送至 {{channel}}(聊天 {{chatId}})", + "run": "发送测试", + "running": "发送中...", + "close": "关闭", + "failed": "测试失败" + }, + "calls": { + "title": "调用历史:{{name}}", + "description": "此 Webhook 的最近调用。", + "statusAll": "全部状态", + "status": { + "queued": "排队中", + "running": "运行中", + "done": "完成", + "failed": "失败", + "dead": "已废弃" + }, + "cols": { + "status": "状态", + "mode": "模式", + "attempts": "尝试次数", + "created": "创建时间", + "detail": "详情" + }, + "emptyTitle": "尚无调用", + "emptyDescription": "对此 Webhook 的调用将显示在此处。", + "noMore": "没有更多调用。", + "page": "第 {{page}} 页", + "prev": "上一页", + "next": "下一页" + }, + "callDetail": { + "title": "调用详情", + "description": "单次 Webhook 调用的完整记录。", + "loading": "加载中...", + "deliveryId": "Delivery ID", + "idempotencyKey": "幂等键", + "callbackUrl": "回调 URL", + "created": "创建时间", + "started": "开始时间", + "completed": "完成时间", + "nextAttempt": "下次重试", + "lastError": "最近错误", + "requestPayload": "请求载荷", + "response": "响应" + }, + "rotate": { + "title": "轮换密钥", + "description": "轮换将使 \"{{name}}\" 的当前密钥失效。新密钥仅显示一次。" + }, + "revoke": { + "title": "吊销 Webhook", + "description": "吊销将永久禁用 \"{{name}}\",且无法撤销。" + }, + "toast": { + "created": "已创建 Webhook", + "updated": "已更新 Webhook", + "secretRotated": "已轮换密钥", + "revoked": "已吊销 Webhook", + "failedCreate": "创建 Webhook 失败", + "failedUpdate": "更新 Webhook 失败", + "failedRotate": "轮换密钥失败", + "failedRevoke": "吊销 Webhook 失败" + } +} diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 46470119..c5dc15de 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -71,6 +71,11 @@ export const queryKeys = { detail: (id: string) => ["hooks", id] as const, history: (id: string) => ["hooks", id, "history"] as const, }, + webhooks: { + all: ["webhooks"] as const, + calls: (id: string, params: Record) => ["webhooks", id, "calls", params] as const, + call: (id: string, callId: string) => ["webhooks", id, "calls", callId] as const, + }, builtinTools: { all: ["builtinTools"] as const, }, diff --git a/ui/web/src/lib/routes.ts b/ui/web/src/lib/routes.ts index 3715f5ed..acb1150c 100644 --- a/ui/web/src/lib/routes.ts +++ b/ui/web/src/lib/routes.ts @@ -47,6 +47,7 @@ export const ROUTES = { SELECT_TENANT: "/select-tenant", HOOKS: "/hooks", HOOK_DETAIL: "/hooks/:id", + WEBHOOKS: "/webhooks", WORKSTATIONS: "/workstations", WORKSTATION_DETAIL: "/workstations/:id", } as const; diff --git a/ui/web/src/pages/webhooks/hooks/use-webhooks.ts b/ui/web/src/pages/webhooks/hooks/use-webhooks.ts new file mode 100644 index 00000000..5d24ec8b --- /dev/null +++ b/ui/web/src/pages/webhooks/hooks/use-webhooks.ts @@ -0,0 +1,137 @@ +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import i18next from "i18next"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import { toast } from "@/stores/use-toast-store"; +import type { + WebhookData, + WebhookCreateInput, + WebhookCreateResponse, + WebhookUpdateInput, + WebhookRotateResponse, + WebhookCallData, + WebhookCallDetail, + WebhookTestInput, + WebhookTestResult, +} from "@/types/webhook"; + +export function useWebhooks() { + const http = useHttp(); + const queryClient = useQueryClient(); + + const { data: webhooks = [], isLoading: loading } = useQuery({ + queryKey: queryKeys.webhooks.all, + queryFn: () => http.get("/v1/webhooks"), + staleTime: 60_000, + }); + + const invalidate = useCallback( + () => queryClient.invalidateQueries({ queryKey: queryKeys.webhooks.all }), + [queryClient], + ); + + const createWebhook = useCallback( + async (data: WebhookCreateInput): Promise => { + try { + const res = await http.post("/v1/webhooks", data); + await invalidate(); + toast.success(i18next.t("webhooks:toast.created")); + return res; + } catch (err) { + toast.error(i18next.t("webhooks:toast.failedCreate"), err instanceof Error ? err.message : ""); + throw err; + } + }, + [http, invalidate], + ); + + const updateWebhook = useCallback( + async (id: string, data: WebhookUpdateInput): Promise => { + try { + await http.patch(`/v1/webhooks/${id}`, data); + await invalidate(); + toast.success(i18next.t("webhooks:toast.updated")); + } catch (err) { + toast.error(i18next.t("webhooks:toast.failedUpdate"), err instanceof Error ? err.message : ""); + throw err; + } + }, + [http, invalidate], + ); + + const rotateSecret = useCallback( + async (id: string): Promise => { + try { + const res = await http.post(`/v1/webhooks/${id}/rotate`, {}); + await invalidate(); + toast.success(i18next.t("webhooks:toast.secretRotated")); + return res; + } catch (err) { + toast.error(i18next.t("webhooks:toast.failedRotate"), err instanceof Error ? err.message : ""); + throw err; + } + }, + [http, invalidate], + ); + + const revokeWebhook = useCallback( + async (id: string): Promise => { + try { + await http.delete(`/v1/webhooks/${id}`); + await invalidate(); + toast.success(i18next.t("webhooks:toast.revoked")); + } catch (err) { + toast.error(i18next.t("webhooks:toast.failedRevoke"), err instanceof Error ? err.message : ""); + throw err; + } + }, + [http, invalidate], + ); + + const testWebhook = useCallback( + (id: string, body: WebhookTestInput): Promise => + http.post(`/v1/webhooks/${id}/test`, body), + [http], + ); + + return { webhooks, loading, refresh: invalidate, createWebhook, updateWebhook, rotateSecret, revokeWebhook, testWebhook }; +} + +export function useWebhookCalls( + id: string | null, + status: string, + enabled: boolean, + limit: number, + offset: number, +) { + const http = useHttp(); + const params = { status, limit, offset }; + + const { data = [], isLoading: loading, isFetching, refetch } = useQuery({ + queryKey: queryKeys.webhooks.calls(id ?? "", params), + queryFn: () => { + const q: Record = { limit: String(limit), offset: String(offset) }; + if (status) q.status = status; + return http.get(`/v1/webhooks/${id}/calls`, q); + }, + enabled: enabled && !!id, + placeholderData: (prev) => prev, + staleTime: 10_000, + }); + + return { calls: data, loading, isFetching, refetch }; +} + +export function useWebhookCallDetail(webhookId: string | null, callId: string | null) { + const http = useHttp(); + + const { data, isLoading: loading } = useQuery({ + queryKey: queryKeys.webhooks.call(webhookId ?? "", callId ?? ""), + queryFn: () => http.get(`/v1/webhooks/${webhookId}/calls/${callId}`), + enabled: !!webhookId && !!callId, + staleTime: 30_000, + }); + + return { detail: data ?? null, loading }; +} diff --git a/ui/web/src/pages/webhooks/webhook-call-detail-dialog.tsx b/ui/web/src/pages/webhooks/webhook-call-detail-dialog.tsx new file mode 100644 index 00000000..e0213a04 --- /dev/null +++ b/ui/web/src/pages/webhooks/webhook-call-detail-dialog.tsx @@ -0,0 +1,113 @@ +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { useWebhookCallDetail } from "./hooks/use-webhooks"; +import type { WebhookCallDetail } from "@/types/webhook"; + +const STATUS_VARIANT: Record = { + queued: "secondary", + running: "outline", + done: "default", + failed: "destructive", + dead: "destructive", +}; + +// Pretty-print a JSON string; fall back to the raw text if it isn't JSON. +function prettyJSON(s?: string): string { + if (!s) return ""; + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return s; + } +} + +function fmt(iso?: string): string { + return iso ? new Date(iso).toLocaleString() : "—"; +} + +interface Props { + webhookId: string | null; + callId: string | null; + onClose: () => void; +} + +export function WebhookCallDetailDialog({ webhookId, callId, onClose }: Props) { + const { t } = useTranslation("webhooks"); + const { detail, loading } = useWebhookCallDetail(webhookId, callId); + + const Field = ({ label, value, mono }: { label: string; value?: string; mono?: boolean }) => ( +
+
{label}
+
{value || "—"}
+
+ ); + + return ( + !open && onClose()}> + + + {t("callDetail.title")} + {t("callDetail.description")} + + + {loading || !detail ? ( +

{t("callDetail.loading")}

+ ) : ( +
+
+
+
{t("calls.cols.status")}
+ + {t(`calls.status.${detail.status}`)} + +
+ + + + + + + + + +
+ + {detail.last_error && ( +
+
{t("callDetail.lastError")}
+
+ {detail.last_error} +
+
+ )} + + {detail.request_payload && ( +
+
{t("callDetail.requestPayload")}
+
+                  {prettyJSON(detail.request_payload)}
+                
+
+ )} + + {detail.response && ( +
+
{t("callDetail.response")}
+
+                  {prettyJSON(detail.response)}
+                
+
+ )} +
+ )} +
+
+ ); +} diff --git a/ui/web/src/pages/webhooks/webhook-calls-dialog.tsx b/ui/web/src/pages/webhooks/webhook-calls-dialog.tsx new file mode 100644 index 00000000..210be879 --- /dev/null +++ b/ui/web/src/pages/webhooks/webhook-calls-dialog.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { RefreshCw, ChevronLeft, ChevronRight } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { EmptyState } from "@/components/shared/empty-state"; +import { Inbox } from "lucide-react"; +import { formatRelativeTime } from "@/lib/format"; +import { useWebhookCalls } from "./hooks/use-webhooks"; +import { WebhookCallDetailDialog } from "./webhook-call-detail-dialog"; +import type { WebhookData, WebhookCallData } from "@/types/webhook"; + +const STATUS_VARIANT: Record = { + queued: "secondary", + running: "outline", + done: "default", + failed: "destructive", + dead: "destructive", +}; + +const ALL = "__all__"; +const PAGE_SIZE = 20; + +interface Props { + webhook: WebhookData | null; + onClose: () => void; +} + +export function WebhookCallsDialog({ webhook, onClose }: Props) { + const { t } = useTranslation("webhooks"); + const [status, setStatus] = useState(ALL); + const [page, setPage] = useState(0); + const [detailCallId, setDetailCallId] = useState(null); + const effectiveStatus = status === ALL ? "" : status; + const { calls, isFetching, refetch } = useWebhookCalls( + webhook?.id ?? null, + effectiveStatus, + !!webhook, + PAGE_SIZE, + page * PAGE_SIZE, + ); + + // Reset to first page when the target webhook or status filter changes. + useEffect(() => { + setPage(0); + }, [webhook?.id, status]); + + // No total count from the API → infer "has more" from a full page. + const hasNext = calls.length === PAGE_SIZE; + const hasPrev = page > 0; + + return ( + <> + !open && onClose()}> + + + {t("calls.title", { name: webhook?.name ?? "" })} + {t("calls.description")} + + +
+ + +
+ +
+ {calls.length === 0 ? ( + page === 0 ? ( + + ) : ( +

{t("calls.noMore")}

+ ) + ) : ( +
+ + + + + + + + + + + + {calls.map((c) => ( + setDetailCallId(c.id)} + className="border-b last:border-0 align-top hover:bg-muted/30 cursor-pointer" + > + + + + + + + ))} + +
{t("calls.cols.status")}{t("calls.cols.mode")}{t("calls.cols.attempts")}{t("calls.cols.created")}{t("calls.cols.detail")}
+ + {t(`calls.status.${c.status}`)} + + {c.mode}{c.attempts} + {formatRelativeTime(c.created_at)} + + {c.last_error ? ( + {c.last_error} + ) : c.response ? ( + {c.response} + ) : ( + "—" + )} +
+
+ )} +
+ + {(hasPrev || hasNext) && ( +
+ {t("calls.page", { page: page + 1 })} +
+ + +
+
+ )} +
+
+ + setDetailCallId(null)} + /> + + ); +} diff --git a/ui/web/src/pages/webhooks/webhook-form-dialog.tsx b/ui/web/src/pages/webhooks/webhook-form-dialog.tsx new file mode 100644 index 00000000..8083d7fa --- /dev/null +++ b/ui/web/src/pages/webhooks/webhook-form-dialog.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Webhook as WebhookIcon } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useAuthStore } from "@/stores/use-auth-store"; +import { useAgents } from "@/pages/agents/hooks/use-agents"; +import { useChannelInstances } from "@/pages/channels/hooks/use-channel-instances"; +import { NONE, WebhookFormFields } from "./webhook-form-fields"; +import type { WebhookData, WebhookKind, WebhookCreateInput, WebhookUpdateInput } from "@/types/webhook"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + editing: WebhookData | null; + onCreate: (input: WebhookCreateInput) => Promise; + onUpdate: (id: string, input: WebhookUpdateInput) => Promise; +} + +export function WebhookFormDialog({ open, onOpenChange, editing, onCreate, onUpdate }: Props) { + const { t } = useTranslation("webhooks"); + const edition = useAuthStore((s) => s.edition); + const isStandard = edition === "standard"; + const { agents } = useAgents(); + const { instances } = useChannelInstances({ limit: 200 }); + + const isEdit = !!editing; + + const [name, setName] = useState(""); + const [kind, setKind] = useState("llm"); + const [agentId, setAgentId] = useState(NONE); + const [channelId, setChannelId] = useState(NONE); + const [rateLimit, setRateLimit] = useState("60"); + const [ipAllowlist, setIpAllowlist] = useState(""); + const [requireHmac, setRequireHmac] = useState(false); + const [localhostOnly, setLocalhostOnly] = useState(!isStandard); + const [saving, setSaving] = useState(false); + + // Hydrate from the editing target (or reset to defaults on open). + useEffect(() => { + if (!open) return; + if (editing) { + setName(editing.name); + setKind(editing.kind); + setAgentId(editing.agent_id ?? NONE); + setChannelId(editing.channel_id ?? NONE); + setRateLimit(String(editing.rate_limit_per_min ?? 0)); + setIpAllowlist((editing.ip_allowlist ?? []).join(", ")); + setRequireHmac(editing.require_hmac); + setLocalhostOnly(editing.localhost_only); + } else { + setName(""); + setKind("llm"); + setAgentId(NONE); + setChannelId(NONE); + setRateLimit("60"); + setIpAllowlist(""); + setRequireHmac(false); + setLocalhostOnly(!isStandard); + } + }, [open, editing, isStandard]); + + const parseIpList = (s: string): string[] => + s.split(",").map((x) => x.trim()).filter(Boolean); + + const handleSubmit = async () => { + if (!name.trim()) return; + setSaving(true); + try { + const rate = parseInt(rateLimit, 10); + if (isEdit && editing) { + const update: WebhookUpdateInput = { + name: name.trim(), + channel_id: channelId === NONE ? undefined : channelId, + rate_limit_per_min: Number.isFinite(rate) ? rate : 0, + ip_allowlist: parseIpList(ipAllowlist), + require_hmac: requireHmac, + localhost_only: localhostOnly, + }; + await onUpdate(editing.id, update); + } else { + const create: WebhookCreateInput = { + name: name.trim(), + kind, + agent_id: kind === "llm" && agentId !== NONE ? agentId : undefined, + channel_id: kind === "message" && channelId !== NONE ? channelId : undefined, + rate_limit_per_min: Number.isFinite(rate) ? rate : undefined, + ip_allowlist: parseIpList(ipAllowlist), + require_hmac: requireHmac, + localhost_only: localhostOnly, + }; + await onCreate(create); + } + onOpenChange(false); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + {isEdit ? t("form.editTitle") : t("form.createTitle")} + + {t("form.description")} + + + + + + + + + + + ); +} diff --git a/ui/web/src/pages/webhooks/webhook-form-fields.tsx b/ui/web/src/pages/webhooks/webhook-form-fields.tsx new file mode 100644 index 00000000..fcd050a0 --- /dev/null +++ b/ui/web/src/pages/webhooks/webhook-form-fields.tsx @@ -0,0 +1,192 @@ +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { WebhookKind } from "@/types/webhook"; + +export const NONE = "__none__"; + +interface AgentOption { + id: string; + display_name?: string | null; + agent_key: string; +} + +interface ChannelOption { + id: string; + display_name?: string | null; + name: string; +} + +interface Props { + isEdit: boolean; + isStandard: boolean; + name: string; + setName: (value: string) => void; + kind: WebhookKind; + setKind: (kind: WebhookKind) => void; + agentId: string; + setAgentId: (id: string) => void; + channelId: string; + setChannelId: (id: string) => void; + rateLimit: string; + setRateLimit: (value: string) => void; + ipAllowlist: string; + setIpAllowlist: (value: string) => void; + requireHmac: boolean; + setRequireHmac: (value: boolean) => void; + localhostOnly: boolean; + setLocalhostOnly: (value: boolean) => void; + agents: AgentOption[]; + instances: ChannelOption[]; +} + +export function WebhookFormFields({ + isEdit, + isStandard, + name, + setName, + kind, + setKind, + agentId, + setAgentId, + channelId, + setChannelId, + rateLimit, + setRateLimit, + ipAllowlist, + setIpAllowlist, + requireHmac, + setRequireHmac, + localhostOnly, + setLocalhostOnly, + agents, + instances, +}: Props) { + const { t } = useTranslation("webhooks"); + + return ( +
+
+ + setName(e.target.value)} + placeholder={t("form.namePlaceholder")} + maxLength={100} + className="text-base md:text-sm" + /> +
+ +
+ + +
+ + {kind === "llm" && ( +
+ + +

{t("form.agentHint")}

+
+ )} + + {kind === "message" && ( +
+ + +
+ )} + +
+ + setRateLimit(e.target.value)} + className="text-base md:text-sm" + /> +
+ +
+ + setIpAllowlist(e.target.value)} + placeholder={t("form.ipAllowlistPlaceholder")} + className="text-base md:text-sm" + /> +

{t("form.ipAllowlistHint")}

+
+ +
+
+ +

{t("form.requireHmacHint")}

+
+ +
+ +
+
+ +

+ {isStandard ? t("form.localhostOnlyHint") : t("form.localhostOnlyLite")} +

+
+ +
+
+ ); +} diff --git a/ui/web/src/pages/webhooks/webhook-list-table.tsx b/ui/web/src/pages/webhooks/webhook-list-table.tsx new file mode 100644 index 00000000..f4326bb1 --- /dev/null +++ b/ui/web/src/pages/webhooks/webhook-list-table.tsx @@ -0,0 +1,151 @@ +import { useTranslation } from "react-i18next"; +import { + Ban, + Bot, + Check, + Copy, + History, + KeyRound, + Pencil, + Play, + Radio, + Webhook as WebhookIcon, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatRelativeTime } from "@/lib/format"; +import type { WebhookData } from "@/types/webhook"; + +interface Props { + webhooks: WebhookData[]; + copiedId: string | null; + onCopyId: (id: string) => void; + agentName: (id?: string) => string; + channelName: (id?: string) => string; + onTest: (webhook: WebhookData) => void; + onCalls: (webhook: WebhookData) => void; + onEdit: (webhook: WebhookData) => void; + onRotate: (webhook: WebhookData) => void; + onRevoke: (webhook: WebhookData) => void; +} + +export function WebhookListTable({ + webhooks, + copiedId, + onCopyId, + agentName, + channelName, + onTest, + onCalls, + onEdit, + onRotate, + onRevoke, +}: Props) { + const { t } = useTranslation("webhooks"); + + return ( +
+ + + + + + + + + + + + + + {webhooks.map((w) => ( + + + + + + + + + + ))} + +
{t("columns.name")}{t("columns.webhookId")}{t("columns.kind")}{t("columns.target")}{t("columns.status")}{t("columns.lastUsed")}{t("columns.actions")}
+
+ +
+
{w.name}
+ {w.secret_prefix}... +
+
+
+
+ + {w.id.slice(0, 8)}... + + +
+
+ {t(`kind.${w.kind}`)} + + {w.kind === "llm" && w.agent_id && ( + + + {agentName(w.agent_id)} + + )} + {w.kind === "message" && w.channel_id && ( + + + {channelName(w.channel_id)} + + )} + {!w.agent_id && !w.channel_id && "-"} + + {w.revoked ? ( + {t("status.revoked")} + ) : ( + {t("status.active")} + )} + + {w.last_used_at ? formatRelativeTime(w.last_used_at) : t("neverUsed")} + +
+ {!w.revoked && (w.kind === "llm" || w.kind === "message") && ( + + )} + + {!w.revoked && ( + <> + + + + + )} +
+
+
+ ); +} diff --git a/ui/web/src/pages/webhooks/webhook-secret-dialog.tsx b/ui/web/src/pages/webhooks/webhook-secret-dialog.tsx new file mode 100644 index 00000000..5bdbf914 --- /dev/null +++ b/ui/web/src/pages/webhooks/webhook-secret-dialog.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Copy, Check, ShieldAlert } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; + +export interface WebhookSecretPayload { + webhookId: string; + secret: string; + hmacSigningKey: string; +} + +interface Props { + payload: WebhookSecretPayload | null; + onClose: () => void; +} + +export function WebhookSecretDialog({ payload, onClose }: Props) { + const { t } = useTranslation("webhooks"); + const [copiedKey, setCopiedKey] = useState(null); + + const copy = async (value: string, field: string) => { + await navigator.clipboard.writeText(value); + setCopiedKey(field); + setTimeout(() => setCopiedKey((k) => (k === field ? null : k)), 2000); + }; + + const Row = ({ label, value, field }: { label: string; value: string; field: string }) => ( +
+ +
+ + {value} + + +
+
+ ); + + return ( + !open && onClose()}> + + + {t("secret.title")} + {t("secret.description")} + + +
+ + {t("secret.warning")} +
+ + {payload && ( +
+ + + +

{t("secret.usageHint")}

+
+ )} + + + + +
+
+ ); +} diff --git a/ui/web/src/pages/webhooks/webhook-test-dialog.tsx b/ui/web/src/pages/webhooks/webhook-test-dialog.tsx new file mode 100644 index 00000000..6a7a662f --- /dev/null +++ b/ui/web/src/pages/webhooks/webhook-test-dialog.tsx @@ -0,0 +1,194 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Play, AlertTriangle } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { toast } from "@/stores/use-toast-store"; +import type { + WebhookData, + WebhookTestInput, + WebhookTestResult, + WebhookTestLLMResult, + WebhookTestMessageResult, +} from "@/types/webhook"; + +interface Props { + webhook: WebhookData | null; + onClose: () => void; + onRun: (id: string, body: WebhookTestInput) => Promise; +} + +function isLLMResult(r: WebhookTestResult): r is WebhookTestLLMResult { + return "output" in r; +} + +export function WebhookTestDialog({ webhook, onClose, onRun }: Props) { + const { t } = useTranslation("webhooks"); + const [input, setInput] = useState(""); + const [channelName, setChannelName] = useState(""); + const [chatId, setChatId] = useState(""); + const [content, setContent] = useState(""); + const [running, setRunning] = useState(false); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (webhook) { + setInput(""); + setChannelName(""); + setChatId(""); + setContent(""); + setResult(null); + setError(null); + } + }, [webhook]); + + const isMessage = webhook?.kind === "message"; + const hasBoundChannel = !!webhook?.channel_id; + + const handleRun = async () => { + if (!webhook) return; + setRunning(true); + setResult(null); + setError(null); + try { + const body: WebhookTestInput = isMessage + ? { + channel_name: hasBoundChannel ? undefined : channelName.trim(), + chat_id: chatId.trim(), + content: content.trim(), + } + : { input: input.trim() }; + const res = await onRun(webhook.id, body); + setResult(res); + } catch (err) { + const msg = err instanceof Error ? err.message : t("test.failed"); + setError(msg); + toast.error(t("test.failed"), msg); + } finally { + setRunning(false); + } + }; + + const canRun = isMessage + ? chatId.trim() !== "" && content.trim() !== "" && (hasBoundChannel || channelName.trim() !== "") + : input.trim() !== ""; + + return ( + !open && onClose()}> + + + {t("test.title", { name: webhook?.name ?? "" })} + {t("test.description")} + + + {isMessage && ( +
+ + {t("test.messageWarning")} +
+ )} + +
+ {isMessage ? ( + <> + {!hasBoundChannel && ( +
+ + setChannelName(e.target.value)} + placeholder={t("test.channelNamePlaceholder")} + className="text-base md:text-sm" + /> +
+ )} +
+ + setChatId(e.target.value)} + placeholder={t("test.chatIdPlaceholder")} + className="text-base md:text-sm" + /> +
+
+ +