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 <duy@wearetopgroup.com>
This commit is contained in:
thotamandGoon authored and GitHub committed 2026-06-21 18:46:37 +07:00
1 parent a5a853f461
commit 21fb1f18ee
31 files changed
+3087 -97

No files matched your search

+15 -3
View File
@@ -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
+351 -5
View File
@@ -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_<base32(24 bytes)>".
+388
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}
+131
View File
@@ -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:
+123
View File
@@ -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(
+23 -22
View File
@@ -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",
+23 -22
View File
@@ -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
+23 -22
View File
@@ -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
+23 -22
View File
@@ -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"
+4
View File
@@ -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) {
<SidebarGroup label={t("groups.connectivity")} collapsed={collapsed}>
<SidebarItem to={ROUTES.CHANNELS} icon={Radio} label={t("nav.channels")} collapsed={collapsed} />
{isAdmin && (
<SidebarItem to={ROUTES.WEBHOOKS} icon={Cable} label={t("nav.webhooks")} collapsed={collapsed} />
)}
<SidebarItem to={ROUTES.NODES} icon={Link} label={t("nav.nodes")} collapsed={collapsed} badge={pendingCount} />
{isAdmin && (
<SidebarItem to={ROUTES.WORKSTATIONS} icon={MonitorCog} label={t("nav.workstations")} collapsed={collapsed} />
+7
View File
@@ -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,
},
},
+1
View File
@@ -16,6 +16,7 @@
"sessions": "Sessions",
"pendingMessages": "Pending Messages",
"channels": "Channels",
"webhooks": "Webhooks",
"contacts": "Contacts",
"skills": "Skills",
"cron": "Cron",
+151
View File
@@ -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 <secret>', 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"
}
}
+1
View File
@@ -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ỳ",
+151
View File
@@ -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 <secret>', 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"
}
}
+1
View File
@@ -15,6 +15,7 @@
"importExport": "Import & Export",
"builtinTools": "内置工具",
"channels": "Channel",
"webhooks": "Webhook",
"contacts": "联系人",
"chat": "对话",
"config": "配置",
+151
View File
@@ -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 <secret>' 发送密钥,或使用 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 失败"
}
}
+5
View File
@@ -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<string, unknown>) => ["webhooks", id, "calls", params] as const,
call: (id: string, callId: string) => ["webhooks", id, "calls", callId] as const,
},
builtinTools: {
all: ["builtinTools"] as const,
},
+1
View File
@@ -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;
@@ -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<WebhookData[]>("/v1/webhooks"),
staleTime: 60_000,
});
const invalidate = useCallback(
() => queryClient.invalidateQueries({ queryKey: queryKeys.webhooks.all }),
[queryClient],
);
const createWebhook = useCallback(
async (data: WebhookCreateInput): Promise<WebhookCreateResponse> => {
try {
const res = await http.post<WebhookCreateResponse>("/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<void> => {
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<WebhookRotateResponse> => {
try {
const res = await http.post<WebhookRotateResponse>(`/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<void> => {
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<WebhookTestResult> =>
http.post<WebhookTestResult>(`/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<string, string> = { limit: String(limit), offset: String(offset) };
if (status) q.status = status;
return http.get<WebhookCallData[]>(`/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<WebhookCallDetail>(`/v1/webhooks/${webhookId}/calls/${callId}`),
enabled: !!webhookId && !!callId,
staleTime: 30_000,
});
return { detail: data ?? null, loading };
}
@@ -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<WebhookCallDetail["status"], "default" | "secondary" | "destructive" | "outline"> = {
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 }) => (
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">{label}</div>
<div className={`text-sm break-all ${mono ? "font-mono" : ""}`}>{value || "—"}</div>
</div>
);
return (
<Dialog open={!!callId} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("callDetail.title")}</DialogTitle>
<DialogDescription>{t("callDetail.description")}</DialogDescription>
</DialogHeader>
{loading || !detail ? (
<p className="py-8 text-center text-sm text-muted-foreground">{t("callDetail.loading")}</p>
) : (
<div className="max-h-[70vh] overflow-y-auto space-y-4">
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<div className="space-y-0.5">
<div className="text-xs text-muted-foreground">{t("calls.cols.status")}</div>
<Badge variant={STATUS_VARIANT[detail.status]} className="text-xs">
{t(`calls.status.${detail.status}`)}
</Badge>
</div>
<Field label={t("calls.cols.mode")} value={detail.mode} />
<Field label={t("calls.cols.attempts")} value={String(detail.attempts)} />
<Field label={t("callDetail.deliveryId")} value={detail.delivery_id} mono />
<Field label={t("callDetail.idempotencyKey")} value={detail.idempotency_key} mono />
<Field label={t("callDetail.callbackUrl")} value={detail.callback_url} mono />
<Field label={t("callDetail.created")} value={fmt(detail.created_at)} />
<Field label={t("callDetail.started")} value={fmt(detail.started_at)} />
<Field label={t("callDetail.completed")} value={fmt(detail.completed_at)} />
<Field label={t("callDetail.nextAttempt")} value={fmt(detail.next_attempt_at)} />
</div>
{detail.last_error && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{t("callDetail.lastError")}</div>
<div className="rounded bg-destructive/10 border border-destructive/40 px-3 py-2 text-sm text-destructive break-words">
{detail.last_error}
</div>
</div>
)}
{detail.request_payload && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{t("callDetail.requestPayload")}</div>
<pre className="max-h-48 overflow-auto rounded bg-muted px-3 py-2 text-xs whitespace-pre-wrap break-words">
{prettyJSON(detail.request_payload)}
</pre>
</div>
)}
{detail.response && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">{t("callDetail.response")}</div>
<pre className="max-h-48 overflow-auto rounded bg-muted px-3 py-2 text-xs whitespace-pre-wrap break-words">
{prettyJSON(detail.response)}
</pre>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -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<WebhookCallData["status"], "default" | "secondary" | "destructive" | "outline"> = {
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<string>(ALL);
const [page, setPage] = useState(0);
const [detailCallId, setDetailCallId] = useState<string | null>(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 (
<>
<Dialog open={!!webhook} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{t("calls.title", { name: webhook?.name ?? "" })}</DialogTitle>
<DialogDescription>{t("calls.description")}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Select value={status} onValueChange={setStatus}>
<SelectTrigger className="w-40 text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>{t("calls.statusAll")}</SelectItem>
<SelectItem value="queued">{t("calls.status.queued")}</SelectItem>
<SelectItem value="running">{t("calls.status.running")}</SelectItem>
<SelectItem value="done">{t("calls.status.done")}</SelectItem>
<SelectItem value="failed">{t("calls.status.failed")}</SelectItem>
<SelectItem value="dead">{t("calls.status.dead")}</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching} className="gap-1">
<RefreshCw className={isFetching ? "animate-spin h-3.5 w-3.5" : "h-3.5 w-3.5"} />
</Button>
</div>
<div className="mt-2 max-h-[60vh] overflow-y-auto">
{calls.length === 0 ? (
page === 0 ? (
<EmptyState icon={Inbox} title={t("calls.emptyTitle")} description={t("calls.emptyDescription")} />
) : (
<p className="py-8 text-center text-sm text-muted-foreground">{t("calls.noMore")}</p>
)
) : (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[600px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-medium">{t("calls.cols.status")}</th>
<th className="px-3 py-2 text-left font-medium">{t("calls.cols.mode")}</th>
<th className="px-3 py-2 text-left font-medium">{t("calls.cols.attempts")}</th>
<th className="px-3 py-2 text-left font-medium">{t("calls.cols.created")}</th>
<th className="px-3 py-2 text-left font-medium">{t("calls.cols.detail")}</th>
</tr>
</thead>
<tbody>
{calls.map((c) => (
<tr
key={c.id}
onClick={() => setDetailCallId(c.id)}
className="border-b last:border-0 align-top hover:bg-muted/30 cursor-pointer"
>
<td className="px-3 py-2">
<Badge variant={STATUS_VARIANT[c.status]} className="text-xs">
{t(`calls.status.${c.status}`)}
</Badge>
</td>
<td className="px-3 py-2 text-muted-foreground">{c.mode}</td>
<td className="px-3 py-2 text-muted-foreground">{c.attempts}</td>
<td className="px-3 py-2 text-muted-foreground" title={new Date(c.created_at).toLocaleString()}>
{formatRelativeTime(c.created_at)}
</td>
<td className="px-3 py-2 text-xs text-muted-foreground max-w-[260px]">
{c.last_error ? (
<span className="text-destructive break-words">{c.last_error}</span>
) : c.response ? (
<span className="break-words line-clamp-3">{c.response}</span>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{(hasPrev || hasNext) && (
<div className="flex items-center justify-between border-t pt-3">
<span className="text-xs text-muted-foreground">{t("calls.page", { page: page + 1 })}</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={!hasPrev || isFetching}
className="gap-1"
>
<ChevronLeft className="h-3.5 w-3.5" /> {t("calls.prev")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={!hasNext || isFetching}
className="gap-1"
>
{t("calls.next")} <ChevronRight className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
<WebhookCallDetailDialog
webhookId={webhook?.id ?? null}
callId={detailCallId}
onClose={() => setDetailCallId(null)}
/>
</>
);
}
@@ -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<void>;
onUpdate: (id: string, input: WebhookUpdateInput) => Promise<void>;
}
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<WebhookKind>("llm");
const [agentId, setAgentId] = useState<string>(NONE);
const [channelId, setChannelId] = useState<string>(NONE);
const [rateLimit, setRateLimit] = useState<string>("60");
const [ipAllowlist, setIpAllowlist] = useState<string>("");
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<WebhookIcon className="h-5 w-5" />
{isEdit ? t("form.editTitle") : t("form.createTitle")}
</DialogTitle>
<DialogDescription>{t("form.description")}</DialogDescription>
</DialogHeader>
<WebhookFormFields
isEdit={isEdit}
isStandard={isStandard}
name={name}
setName={setName}
kind={kind}
setKind={setKind}
agentId={agentId}
setAgentId={setAgentId}
channelId={channelId}
setChannelId={setChannelId}
rateLimit={rateLimit}
setRateLimit={setRateLimit}
ipAllowlist={ipAllowlist}
setIpAllowlist={setIpAllowlist}
requireHmac={requireHmac}
setRequireHmac={setRequireHmac}
localhostOnly={localhostOnly}
setLocalhostOnly={setLocalhostOnly}
agents={agents}
instances={instances}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t("form.cancel")}
</Button>
<Button type="button" onClick={handleSubmit} disabled={saving || !name.trim()}>
{saving ? t("form.saving") : isEdit ? t("form.save") : t("form.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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 (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="wh-name">{t("form.name")}</Label>
<Input
id="wh-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("form.namePlaceholder")}
maxLength={100}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wh-kind">{t("form.kind")}</Label>
<Select value={kind} onValueChange={(v) => setKind(v as WebhookKind)} disabled={isEdit}>
<SelectTrigger id="wh-kind" className="text-base md:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="llm">{t("kind.llm")}</SelectItem>
<SelectItem value="message" disabled={!isStandard}>
{t("kind.message")}
{!isStandard ? ` - ${t("form.messageStandardOnly")}` : ""}
</SelectItem>
</SelectContent>
</Select>
</div>
{kind === "llm" && (
<div className="space-y-1.5">
<Label htmlFor="wh-agent">{t("form.agent")}</Label>
<Select value={agentId} onValueChange={setAgentId} disabled={isEdit}>
<SelectTrigger id="wh-agent" className="text-base md:text-sm">
<SelectValue placeholder={t("form.agentPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>{t("form.none")}</SelectItem>
{agents.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.display_name || a.agent_key}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("form.agentHint")}</p>
</div>
)}
{kind === "message" && (
<div className="space-y-1.5">
<Label htmlFor="wh-channel">{t("form.channel")}</Label>
<Select value={channelId} onValueChange={setChannelId}>
<SelectTrigger id="wh-channel" className="text-base md:text-sm">
<SelectValue placeholder={t("form.channelPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>{t("form.channelAny")}</SelectItem>
{instances.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.display_name || c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="wh-rate">{t("form.rateLimit")}</Label>
<Input
id="wh-rate"
type="number"
min={0}
value={rateLimit}
onChange={(e) => setRateLimit(e.target.value)}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="wh-ips">{t("form.ipAllowlist")}</Label>
<Input
id="wh-ips"
value={ipAllowlist}
onChange={(e) => setIpAllowlist(e.target.value)}
placeholder={t("form.ipAllowlistPlaceholder")}
className="text-base md:text-sm"
/>
<p className="text-xs text-muted-foreground">{t("form.ipAllowlistHint")}</p>
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="wh-hmac">{t("form.requireHmac")}</Label>
<p className="text-xs text-muted-foreground">{t("form.requireHmacHint")}</p>
</div>
<Switch id="wh-hmac" checked={requireHmac} onCheckedChange={setRequireHmac} />
</div>
<div className="flex items-center justify-between">
<div>
<Label htmlFor="wh-localhost">{t("form.localhostOnly")}</Label>
<p className="text-xs text-muted-foreground">
{isStandard ? t("form.localhostOnlyHint") : t("form.localhostOnlyLite")}
</p>
</div>
<Switch
id="wh-localhost"
checked={localhostOnly}
onCheckedChange={setLocalhostOnly}
disabled={!isStandard}
/>
</div>
</div>
);
}
@@ -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 (
<div className="overflow-x-auto rounded-md border">
<table className="w-full min-w-[800px] text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.webhookId")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.kind")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.target")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
<th className="px-4 py-3 text-left font-medium">{t("columns.lastUsed")}</th>
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
</tr>
</thead>
<tbody>
{webhooks.map((w) => (
<tr key={w.id} className={`border-b last:border-0 hover:bg-muted/30 ${w.revoked ? "opacity-50" : ""}`}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<WebhookIcon className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
<div>
<div className="font-medium">{w.name}</div>
<code className="text-xs-plus text-muted-foreground font-mono">{w.secret_prefix}...</code>
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<code className="text-xs font-mono text-muted-foreground" title={w.id}>
{w.id.slice(0, 8)}...
</code>
<button
onClick={() => onCopyId(w.id)}
className="ml-1 rounded p-0.5 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title={w.id}
>
{copiedId === w.id ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
</button>
</div>
</td>
<td className="px-4 py-3">
<Badge variant="secondary" className="text-xs">{t(`kind.${w.kind}`)}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{w.kind === "llm" && w.agent_id && (
<span className="inline-flex items-center gap-1">
<Bot className="h-3.5 w-3.5" />
{agentName(w.agent_id)}
</span>
)}
{w.kind === "message" && w.channel_id && (
<span className="inline-flex items-center gap-1">
<Radio className="h-3.5 w-3.5" />
{channelName(w.channel_id)}
</span>
)}
{!w.agent_id && !w.channel_id && "-"}
</td>
<td className="px-4 py-3">
{w.revoked ? (
<Badge variant="destructive" className="text-xs">{t("status.revoked")}</Badge>
) : (
<Badge variant="default" className="text-xs">{t("status.active")}</Badge>
)}
</td>
<td className="px-4 py-3 text-muted-foreground" title={w.last_used_at ? new Date(w.last_used_at).toLocaleString() : undefined}>
{w.last_used_at ? formatRelativeTime(w.last_used_at) : t("neverUsed")}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
{!w.revoked && (w.kind === "llm" || w.kind === "message") && (
<Button variant="ghost" size="sm" onClick={() => onTest(w)} title={t("actions.test")}>
<Play className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="sm" onClick={() => onCalls(w)} title={t("actions.calls")}>
<History className="h-3.5 w-3.5" />
</Button>
{!w.revoked && (
<>
<Button variant="ghost" size="sm" onClick={() => onEdit(w)} title={t("actions.edit")}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onRotate(w)} title={t("actions.rotate")}>
<KeyRound className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onRevoke(w)}
className="text-destructive hover:text-destructive"
title={t("actions.revoke")}
>
<Ban className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -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<string | null>(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 }) => (
<div className="space-y-1.5">
<Label>{label}</Label>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded bg-muted px-3 py-2 text-base md:text-sm font-mono break-all">
{value}
</code>
<Button variant="outline" size="sm" onClick={() => copy(value, field)} className="gap-1 shrink-0">
{copiedKey === field ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copiedKey === field ? t("secret.copied") : t("secret.copy")}
</Button>
</div>
</div>
);
return (
<Dialog open={!!payload} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("secret.title")}</DialogTitle>
<DialogDescription>{t("secret.description")}</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
<ShieldAlert className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<span>{t("secret.warning")}</span>
</div>
{payload && (
<div className="space-y-4">
<Row label={t("secret.webhookId")} value={payload.webhookId} field="webhookId" />
<Row label={t("secret.bearer")} value={payload.secret} field="secret" />
<Row label={t("secret.hmacKey")} value={payload.hmacSigningKey} field="hmac" />
<p className="text-xs text-muted-foreground">{t("secret.usageHint")}</p>
</div>
)}
<DialogFooter>
<Button onClick={onClose}>{t("secret.done")}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<WebhookTestResult>;
}
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<WebhookTestResult | null>(null);
const [error, setError] = useState<string | null>(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 (
<Dialog open={!!webhook} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-sm:inset-0 max-sm:translate-x-0 max-sm:translate-y-0 sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t("test.title", { name: webhook?.name ?? "" })}</DialogTitle>
<DialogDescription>{t("test.description")}</DialogDescription>
</DialogHeader>
{isMessage && (
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-sm">
<AlertTriangle className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
<span>{t("test.messageWarning")}</span>
</div>
)}
<div className="space-y-4">
{isMessage ? (
<>
{!hasBoundChannel && (
<div className="space-y-1.5">
<Label htmlFor="test-channel">{t("test.channelName")}</Label>
<Input
id="test-channel"
value={channelName}
onChange={(e) => setChannelName(e.target.value)}
placeholder={t("test.channelNamePlaceholder")}
className="text-base md:text-sm"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="test-chat">{t("test.chatId")}</Label>
<Input
id="test-chat"
value={chatId}
onChange={(e) => setChatId(e.target.value)}
placeholder={t("test.chatIdPlaceholder")}
className="text-base md:text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="test-content">{t("test.content")}</Label>
<Textarea
id="test-content"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
className="text-base md:text-sm"
/>
</div>
</>
) : (
<div className="space-y-1.5">
<Label htmlFor="test-input">{t("test.input")}</Label>
<Textarea
id="test-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={t("test.inputPlaceholder")}
rows={4}
className="text-base md:text-sm"
/>
</div>
)}
{error && (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive break-words">
{error}
</div>
)}
{result && (
<div className="space-y-1.5">
<Label>{t("test.result")}</Label>
{isLLMResult(result) ? (
<pre className="max-h-60 overflow-auto rounded bg-muted px-3 py-2 text-xs whitespace-pre-wrap break-words">
{result.output}
</pre>
) : (
<div className="rounded bg-muted px-3 py-2 text-sm">
{t("test.sent", {
channel: (result as WebhookTestMessageResult).channel_name,
chatId: (result as WebhookTestMessageResult).chat_id,
})}
{(result as WebhookTestMessageResult).warning && (
<p className="text-xs text-amber-600 mt-1">
{(result as WebhookTestMessageResult).warning}
</p>
)}
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("test.close")}
</Button>
<Button type="button" onClick={handleRun} disabled={running || !canRun} className="gap-1">
<Play className="h-3.5 w-3.5" />
{running ? t("test.running") : t("test.run")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, RefreshCw, Webhook as WebhookIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { PageHeader } from "@/components/shared/page-header";
import { EmptyState } from "@/components/shared/empty-state";
import { SearchInput } from "@/components/shared/search-input";
import { TableSkeleton } from "@/components/shared/loading-skeleton";
import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
import { useMinLoading } from "@/hooks/use-min-loading";
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
import { useAgents } from "@/pages/agents/hooks/use-agents";
import { useChannelInstances } from "@/pages/channels/hooks/use-channel-instances";
import { useWebhooks } from "./hooks/use-webhooks";
import { WebhookListTable } from "./webhook-list-table";
import { WebhookFormDialog } from "./webhook-form-dialog";
import { WebhookSecretDialog, type WebhookSecretPayload } from "./webhook-secret-dialog";
import { WebhookTestDialog } from "./webhook-test-dialog";
import { WebhookCallsDialog } from "./webhook-calls-dialog";
import type { WebhookData } from "@/types/webhook";
export function WebhooksPage() {
const { t } = useTranslation("webhooks");
const { t: tc } = useTranslation("common");
const { webhooks, loading, refresh, createWebhook, updateWebhook, rotateSecret, revokeWebhook, testWebhook } = useWebhooks();
const { agents } = useAgents();
const { instances } = useChannelInstances({ limit: 200 });
const spinning = useMinLoading(loading);
const showSkeleton = useDeferredLoading(loading && webhooks.length === 0);
const [search, setSearch] = useState("");
const [showRevoked, setShowRevoked] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<WebhookData | null>(null);
const [secret, setSecret] = useState<WebhookSecretPayload | null>(null);
const [testTarget, setTestTarget] = useState<WebhookData | null>(null);
const [callsTarget, setCallsTarget] = useState<WebhookData | null>(null);
const [revokeTarget, setRevokeTarget] = useState<WebhookData | null>(null);
const [rotateTarget, setRotateTarget] = useState<WebhookData | null>(null);
const [actionLoading, setActionLoading] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const copyId = async (id: string) => {
await navigator.clipboard.writeText(id);
setCopiedId(id);
setTimeout(() => setCopiedId((v) => (v === id ? null : v)), 2000);
};
const agentName = (id?: string) => (id ? agents.find((a) => a.id === id)?.display_name || agents.find((a) => a.id === id)?.agent_key || id.slice(0, 8) : "");
const channelName = (id?: string) => (id ? instances.find((c) => c.id === id)?.display_name || instances.find((c) => c.id === id)?.name || id.slice(0, 8) : "");
const filtered = useMemo(
() =>
webhooks
.filter((w) => (showRevoked ? true : !w.revoked))
.filter((w) => w.name.toLowerCase().includes(search.toLowerCase()) || w.secret_prefix.includes(search)),
[webhooks, showRevoked, search],
);
const openCreate = () => {
setEditing(null);
setFormOpen(true);
};
const openEdit = (w: WebhookData) => {
setEditing(w);
setFormOpen(true);
};
const handleRevoke = async () => {
if (!revokeTarget) return;
setActionLoading(true);
try {
await revokeWebhook(revokeTarget.id);
setRevokeTarget(null);
} finally {
setActionLoading(false);
}
};
const handleRotate = async () => {
if (!rotateTarget) return;
setActionLoading(true);
try {
const res = await rotateSecret(rotateTarget.id);
const rid = rotateTarget.id;
setRotateTarget(null);
setSecret({ webhookId: rid, secret: res.secret, hmacSigningKey: res.hmac_signing_key });
} finally {
setActionLoading(false);
}
};
return (
<div className="p-4 sm:p-6 pb-10">
<PageHeader
title={t("title")}
description={t("description")}
actions={
<div className="flex gap-2">
<Button size="sm" onClick={openCreate} className="gap-1">
<Plus className="h-3.5 w-3.5" /> {t("addWebhook")}
</Button>
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
<RefreshCw className={spinning ? "animate-spin h-3.5 w-3.5" : "h-3.5 w-3.5"} /> {tc("refresh")}
</Button>
</div>
}
/>
<div className="mt-4 flex flex-wrap items-center gap-4">
<SearchInput value={search} onChange={setSearch} placeholder={t("searchPlaceholder")} className="max-w-sm" />
<div className="flex items-center gap-2">
<Switch id="show-revoked" checked={showRevoked} onCheckedChange={setShowRevoked} />
<Label htmlFor="show-revoked" className="text-sm text-muted-foreground">{t("showRevoked")}</Label>
</div>
</div>
<div className="mt-4">
{showSkeleton ? (
<TableSkeleton rows={5} />
) : filtered.length === 0 ? (
<EmptyState icon={WebhookIcon} title={t("emptyTitle")} description={t("emptyDescription")} />
) : (
<WebhookListTable
webhooks={filtered}
copiedId={copiedId}
onCopyId={copyId}
agentName={agentName}
channelName={channelName}
onTest={setTestTarget}
onCalls={setCallsTarget}
onEdit={openEdit}
onRotate={setRotateTarget}
onRevoke={setRevokeTarget}
/>
)}
</div>
<WebhookFormDialog
open={formOpen}
onOpenChange={setFormOpen}
editing={editing}
onCreate={async (input) => {
const res = await createWebhook(input);
setSecret({ webhookId: res.id, secret: res.secret, hmacSigningKey: res.hmac_signing_key });
}}
onUpdate={updateWebhook}
/>
<WebhookSecretDialog payload={secret} onClose={() => setSecret(null)} />
<WebhookTestDialog webhook={testTarget} onClose={() => setTestTarget(null)} onRun={testWebhook} />
<WebhookCallsDialog webhook={callsTarget} onClose={() => setCallsTarget(null)} />
<ConfirmDeleteDialog
open={!!revokeTarget}
onOpenChange={(v) => !v && setRevokeTarget(null)}
title={t("revoke.title")}
description={t("revoke.description", { name: revokeTarget?.name })}
confirmValue={revokeTarget?.name || ""}
confirmLabel={t("actions.revoke")}
onConfirm={handleRevoke}
loading={actionLoading}
/>
<ConfirmDeleteDialog
open={!!rotateTarget}
onOpenChange={(v) => !v && setRotateTarget(null)}
title={t("rotate.title")}
description={t("rotate.description", { name: rotateTarget?.name })}
confirmValue={rotateTarget?.name || ""}
confirmLabel={t("actions.rotate")}
onConfirm={handleRotate}
loading={actionLoading}
/>
</div>
);
}
+4
View File
@@ -114,6 +114,9 @@ const BackupRestorePage = lazyWithRetry(() =>
const HooksPage = lazyWithRetry(() =>
import("@/pages/hooks").then((m) => ({ default: m.HooksPage })),
);
const WebhooksPage = lazyWithRetry(() =>
import("@/pages/webhooks/webhooks-page").then((m) => ({ default: m.WebhooksPage })),
);
const WorkstationsPage = lazyWithRetry(() =>
import("@/pages/workstations/workstations-page").then((m) => ({ default: m.WorkstationsPage })),
);
@@ -185,6 +188,7 @@ export function AppRoutes() {
<Route path={ROUTES.API_KEYS} element={<RequireAdmin><ApiKeysPage /></RequireAdmin>} />
<Route path={ROUTES.CHANNELS} element={<RequireAdmin><ChannelsPage key="list" /></RequireAdmin>} />
<Route path={ROUTES.CHANNEL_DETAIL} element={<RequireAdmin><ChannelsPage key="detail" /></RequireAdmin>} />
<Route path={ROUTES.WEBHOOKS} element={<RequireAdmin><WebhooksPage /></RequireAdmin>} />
<Route path={ROUTES.NODES} element={<RequireAdmin><NodesPage /></RequireAdmin>} />
<Route path={ROUTES.WORKSTATIONS} element={<RequireAdmin><WorkstationsPage /></RequireAdmin>} />
<Route path={ROUTES.LOGS} element={<RequireAdmin><LogsPage /></RequireAdmin>} />
+127
View File
@@ -0,0 +1,127 @@
// Webhook registry + delivery-history types. Mirrors internal/store/webhook_store.go
// (WebhookData / webhookCallResp) and internal/http/webhooks_admin.go request shapes.
export type WebhookKind = "llm" | "message";
export interface WebhookData {
id: string;
tenant_id: string;
agent_id?: string;
name: string;
kind: WebhookKind;
secret_prefix: string;
scopes: string[];
channel_id?: string;
rate_limit_per_min: number;
ip_allowlist: string[];
require_hmac: boolean;
localhost_only: boolean;
revoked: boolean;
created_by: string;
created_at: string;
updated_at: string;
last_used_at?: string;
}
// Create payload — POST /v1/webhooks.
export interface WebhookCreateInput {
name: string;
kind: WebhookKind;
agent_id?: string;
channel_id?: string;
rate_limit_per_min?: number;
ip_allowlist?: string[];
require_hmac?: boolean;
localhost_only?: boolean;
}
// PATCH /v1/webhooks/{id} — kind/agent_id are immutable after create.
export interface WebhookUpdateInput {
name?: string;
channel_id?: string;
rate_limit_per_min?: number;
ip_allowlist?: string[];
require_hmac?: boolean;
localhost_only?: boolean;
}
// Response from create — secret + hmac_signing_key shown ONCE.
export interface WebhookCreateResponse extends WebhookData {
secret: string;
hmac_signing_key: string;
}
// Response from POST /v1/webhooks/{id}/rotate — secret shown ONCE.
export interface WebhookRotateResponse {
id: string;
secret: string;
hmac_signing_key: string;
secret_prefix: string;
}
// Delivery-history record — GET /v1/webhooks/{id}/calls (webhookCallResp DTO).
export interface WebhookCallData {
id: string;
delivery_id: string;
mode: "sync" | "async";
status: "queued" | "running" | "done" | "failed" | "dead";
attempts: number;
next_attempt_at?: string;
started_at?: string;
completed_at?: string;
created_at: string;
last_error?: string;
response?: string;
}
// Full delivery detail — GET /v1/webhooks/{id}/calls/{callId} (webhookCallDetailResp DTO).
export interface WebhookCallDetail {
id: string;
webhook_id: string;
agent_id?: string;
delivery_id: string;
idempotency_key?: string;
mode: "sync" | "async";
status: "queued" | "running" | "done" | "failed" | "dead";
callback_url?: string;
attempts: number;
next_attempt_at?: string;
started_at?: string;
completed_at?: string;
created_at: string;
last_error?: string;
request_payload?: string;
response?: string;
}
// POST /v1/webhooks/{id}/test request — fields used depend on webhook kind.
export interface WebhookTestInput {
// llm
input?: string;
model?: string;
// message
channel_name?: string;
chat_id?: string;
content?: string;
media_url?: string;
media_caption?: string;
fallback_to_text?: boolean;
}
export interface WebhookTestLLMResult {
call_id: string;
agent_id: string;
output: string;
finish_reason: string;
usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number };
}
export interface WebhookTestMessageResult {
call_id: string;
status: string;
channel_name: string;
chat_id: string;
warning?: string;
}
export type WebhookTestResult = WebhookTestLLMResult | WebhookTestMessageResult;