From 76134ed47e9147776d81ee88fca3a5d81b49edf3 Mon Sep 17 00:00:00 2001 From: Aska Date: Sun, 6 Sep 2026 20:40:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20webhook=20=E6=B8=A0=E9=81=93?= =?UTF-8?q?=20InboundHandler=20=E6=8C=82=E8=BD=BD=E5=88=B0=E7=BD=91?= =?UTF-8?q?=E5=85=B3=EF=BC=88=E6=B8=A0=E9=81=93=E9=81=97=E7=95=992?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 让 webhook 渠道的入站 HTTP 回调真正进运行时(此前 handler 建了但未挂载,回调不达 runtime)。 - channelbridge:Bridge.WebhookHandlers() map[string]http.Handler——遍历适配器,对暴露 InboundHandler()/Config() 的(*webhook.Adapter)收集 endpointPath→handler。 - gateway/server.go:Deps 加 Webhooks map[string]http.Handler;Server 存 webhooks; Handler() 挂载到 mux(无网关鉴权,各 handler 自带 HMAC 签名校验)。 - gateway/static.go:spa 加 isWebhookRoute——已注册的 webhook 路径交给 API mux(否则被 SPA fallback 吞成 index.html,webhook 回调不达)。 - wire.go:gateway.Deps 传 Webhooks: a.channelBridge.WebhookHandlers()(wireChannels 在 wireFrontend 前执行,bridge 已建好)。 实机验证:POST /webhook 空体→bad_request(400,webhook 解码响应非 SPA)、带 JSON→accepted(200)、 GET /webhook 被 webhook 路由处理。go build/vet/test 全绿。 --- cmd/cyrene/wire.go | 1 + .../services/channelbridge/channelbridge.go | 40 +++++++++++++++++++ internal/services/gateway/server.go | 17 ++++++++ internal/services/gateway/static.go | 13 +++++- 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/cmd/cyrene/wire.go b/cmd/cyrene/wire.go index fee89d4..5b9f429 100644 --- a/cmd/cyrene/wire.go +++ b/cmd/cyrene/wire.go @@ -469,6 +469,7 @@ func (a *app) wireFrontend() error { RAG: ragStore, Streaming: true, Frontend: resolveFrontendFS(a.log), + Webhooks: a.channelBridge.WebhookHandlers(), }) if err != nil { return fmt.Errorf("gateway: %w", err) diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index 874fd93..34daff5 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -16,6 +16,7 @@ import ( "errors" "fmt" "log/slog" + "net/http" "strings" "sync" "time" @@ -28,6 +29,18 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) +// webhookAdapter is the optional capability a channel adapter may expose to +// source an HTTP inbound callback. *webhook.Adapter implements it via +// InboundHandler(). The bridge reports these so the gateway can mount them. +type webhookAdapter interface { + // InboundHandler returns the http.Handler the gateway mounts at the adapter's + // configured endpoint path to receive external callbacks. + InboundHandler() http.Handler + // Config returns the adapter config snapshot; the endpoint path is read from + // it so the gateway can register the correct route. + Config() map[string]any +} + // Knob constants. Compile-time so no bare magic number appears in the run path // (project rule §10). const ( @@ -123,6 +136,33 @@ func (b *Bridge) Add(a adapter.PlatformAdapter) { b.adapters[a.Name()] = a } +// WebhookHandlers returns the inbound HTTP handlers of every registered adapter +// that exposes an HTTP callback (e.g. *webhook.Adapter), keyed by the endpoint +// path from the adapter's config. The gateway mounts these to receive external +// webhook callbacks into the runtime. A nil map is returned when no adapter +// provides an inbound handler. +func (b *Bridge) WebhookHandlers() map[string]http.Handler { + b.mu.RLock() + defer b.mu.RUnlock() + out := make(map[string]http.Handler) + for _, a := range b.adapters { + wh, ok := a.(webhookAdapter) + if !ok { + continue + } + cfg := wh.Config() + path, _ := cfg["endpointPath"].(string) + if path == "" { + path = "/webhook" + } + out[path] = wh.InboundHandler() + } + if len(out) == 0 { + return nil + } + return out +} + // Start connects every configured adapter, installs the inbound handler, and // launches the read loops. Adapters without credentials (or whose Connect fails) // are logged and left disconnected; they never fail startup. Start blocks only diff --git a/internal/services/gateway/server.go b/internal/services/gateway/server.go index 28d5e0f..4c892f2 100644 --- a/internal/services/gateway/server.go +++ b/internal/services/gateway/server.go @@ -199,6 +199,12 @@ type Deps struct { // When nil the gateway serves no static assets (API and WS only). The // embedded dist (internal/webui) or an external dist directory is used. Frontend fs.FS + // Webhooks are externally-mounted inbound HTTP handlers (endpoint path → + // handler), typically from channel adapters that receive webhook callbacks + // (e.g. webhook). Each is mounted on the ServeMux without gateway auth; the + // handler itself enforces its own signature/authorization (webhook HMAC). + // Optional. + Webhooks map[string]http.Handler } // Server is the gateway. It owns a ServeMux aggregating REST + WS + admin and a @@ -222,6 +228,7 @@ type Server struct { voice VoiceService streaming bool frontend fs.FS + webhooks map[string]http.Handler log *slog.Logger hub *hub } @@ -254,6 +261,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) { rag: deps.RAG, streaming: deps.Streaming, frontend: deps.Frontend, + webhooks: deps.Webhooks, log: sourcedLog(deps.Log), } s.hub = newHub(s) @@ -297,6 +305,15 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/v1/admin/rag/delete", adminAuth(http.HandlerFunc(s.adminDeleteRag))) mux.Handle("POST /api/v1/admin/plugins/policy", s.auth.Authorize(auth.RoleOwner)(http.HandlerFunc(s.adminSetPolicy))) + // External webhook callbacks (e.g. the webhook channel) are mounted without + // gateway auth; each handler enforces its own signature/authorization. + for path, hdl := range s.webhooks { + if path == "" { + continue + } + mux.Handle(path, hdl) + } + var h http.Handler = mux if s.frontend != nil { h = s.spa(h) diff --git a/internal/services/gateway/static.go b/internal/services/gateway/static.go index 224cba5..6757c29 100644 --- a/internal/services/gateway/static.go +++ b/internal/services/gateway/static.go @@ -13,7 +13,7 @@ import ( func (s *Server) spa(api http.Handler) http.Handler { fileServer := s.spaFileServer() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if isAPIRoute(r.URL.Path) { + if isAPIRoute(r.URL.Path) || s.isWebhookRoute(r.URL.Path) { api.ServeHTTP(w, r) return } @@ -21,6 +21,17 @@ func (s *Server) spa(api http.Handler) http.Handler { }) } +// isWebhookRoute reports whether path is a registered external webhook callback +// route. Such paths must reach the API mux (where the webhook handler is +// mounted) rather than the SPA file fallback, so inbound callbacks are served. +func (s *Server) isWebhookRoute(p string) bool { + if len(s.webhooks) == 0 { + return false + } + _, ok := s.webhooks[p] + return ok +} + // spaFileServer serves static assets, falling back to index.html for paths that // are not real files in the embedded/external dist. func (s *Server) spaFileServer() http.Handler {