From 6e7301ed9403cace9b85c64d0dcc0403a9be577d Mon Sep 17 00:00:00 2001 From: viettranx Date: Tue, 3 Mar 2026 10:32:29 +0700 Subject: [PATCH] feat: Mount channel webhook handlers directly on the main gateway. --- cmd/gateway.go | 8 ++++ internal/channels/channel.go | 12 ++++++ internal/channels/feishu/feishu.go | 44 +++++++++++++++++--- internal/channels/manager.go | 24 +++++++++++ ui/web/src/pages/channels/channel-schemas.ts | 4 +- 5 files changed, 85 insertions(+), 7 deletions(-) diff --git a/cmd/gateway.go b/cmd/gateway.go index 1920557e..17d9ef84 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -937,6 +937,14 @@ func runGateway() { // so the same routes are served on both the main listener and Tailscale. // Compiled via build tags: `go build -tags tsnet` to enable. mux := server.BuildMux() + + // Mount channel webhook handlers on the main mux (e.g. Feishu /feishu/events). + // This allows webhook-based channels to share the main server port. + for _, route := range channelMgr.WebhookHandlers() { + mux.Handle(route.Path, route.Handler) + slog.Info("webhook route mounted on gateway", "path", route.Path) + } + tsCleanup := initTailscale(ctx, cfg, mux) if tsCleanup != nil { defer tsCleanup() diff --git a/internal/channels/channel.go b/internal/channels/channel.go index ba03eb94..056a9b18 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -11,6 +11,7 @@ package channels import ( "context" "log/slog" + "net/http" "strings" "github.com/nextlevelbuilder/goclaw/internal/bus" @@ -84,6 +85,17 @@ type StreamingChannel interface { OnStreamEnd(ctx context.Context, chatID string, finalText string) error } +// WebhookChannel extends Channel with an HTTP handler that can be mounted +// on the main gateway mux instead of starting a separate HTTP server. +// This allows webhook-based channels (e.g. Feishu/Lark) to share the main +// server port, avoiding the need to expose additional ports in Docker. +type WebhookChannel interface { + Channel + // WebhookHandler returns the HTTP handler and the path it should be mounted on. + // Returns ("", nil) if the channel doesn't use webhook mode. + WebhookHandler() (path string, handler http.Handler) +} + // ReactionChannel extends Channel with status reaction support. // Channels that implement this interface can show emoji reactions on user messages // to indicate agent status (thinking, tool call, done, error, stall). diff --git a/internal/channels/feishu/feishu.go b/internal/channels/feishu/feishu.go index 91c65c89..3b04b248 100644 --- a/internal/channels/feishu/feishu.go +++ b/internal/channels/feishu/feishu.go @@ -217,16 +217,41 @@ func (c *Channel) startWebSocket(ctx context.Context) error { return nil } -func (c *Channel) startWebhook(ctx context.Context) error { - port := c.cfg.WebhookPort - if port <= 0 { - port = defaultWebhookPort +// WebhookHandler returns the webhook HTTP handler and path for mounting on the main gateway mux. +// Returns ("", nil) if not in webhook mode or if webhook_port > 0 (separate server). +func (c *Channel) WebhookHandler() (string, http.Handler) { + mode := c.cfg.ConnectionMode + if mode != "webhook" { + return "", nil } + // Only mount on main mux when webhook_port is 0 (share main server port). + if c.cfg.WebhookPort > 0 { + return "", nil + } + path := c.cfg.WebhookPath if path == "" { path = defaultWebhookPath } + handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) { + c.handleMessageEvent(context.Background(), event) + }) + + return path, http.HandlerFunc(handler) +} + +func (c *Channel) startWebhook(ctx context.Context) error { + // If webhook_port is 0, the handler is mounted on the main gateway mux + // via WebhookHandler() — no separate server needed. + if c.cfg.WebhookPort <= 0 { + slog.Info("feishu: webhook handler mounted on main gateway mux", "path", c.webhookPath()) + return nil + } + + port := c.cfg.WebhookPort + path := c.webhookPath() + slog.Info("feishu: starting Webhook server", "port", port, "path", path) handler := NewWebhookHandler(c.cfg.VerificationToken, c.cfg.EncryptKey, func(event *MessageEvent) { @@ -312,6 +337,14 @@ func (c *Channel) sendMarkdownCard(ctx context.Context, chatID, receiveIDType, t return nil } +// webhookPath returns the configured webhook path or the default. +func (c *Channel) webhookPath() string { + if c.cfg.WebhookPath != "" { + return c.cfg.WebhookPath + } + return defaultWebhookPath +} + // --- Domain resolution --- func resolveDomain(domain string) string { @@ -396,5 +429,6 @@ func (c *Channel) isDuplicate(messageID string) bool { return loaded } -// Ensure Channel implements the channels.Channel interface at compile time. +// Ensure Channel implements the channels.Channel and WebhookChannel interfaces at compile time. var _ channels.Channel = (*Channel)(nil) +var _ channels.WebhookChannel = (*Channel)(nil) diff --git a/internal/channels/manager.go b/internal/channels/manager.go index b8e08e09..0e8058a5 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "net/http" "os" "regexp" "strings" @@ -213,6 +214,29 @@ func (m *Manager) UnregisterChannel(name string) { delete(m.channels, name) } +// WebhookRoute holds a path and handler pair for mounting on the main gateway mux. +type WebhookRoute struct { + Path string + Handler http.Handler +} + +// WebhookHandlers returns all webhook handlers from channels that implement WebhookChannel. +// Used to mount webhook routes on the main gateway mux. +func (m *Manager) WebhookHandlers() []WebhookRoute { + m.mu.RLock() + defer m.mu.RUnlock() + + var routes []WebhookRoute + for _, ch := range m.channels { + if wh, ok := ch.(WebhookChannel); ok { + if path, handler := wh.WebhookHandler(); path != "" && handler != nil { + routes = append(routes, WebhookRoute{Path: path, Handler: handler}) + } + } + } + return routes +} + // SendToChannel delivers a message to a specific channel by name. func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { m.mu.RLock() diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index 91b22ab9..1a176529 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -77,8 +77,8 @@ export const configSchema: Record = { feishu: [ { key: "domain", label: "Domain", type: "select", options: [{ value: "lark", label: "Lark (global) — webhook only" }, { value: "feishu", label: "Feishu (China)" }], defaultValue: "lark" }, { key: "connection_mode", label: "Connection Mode", type: "select", options: [{ value: "webhook", label: "Webhook" }, { value: "websocket", label: "WebSocket (Feishu only)" }], defaultValue: "webhook", help: "Lark Global only supports Webhook" }, - { key: "webhook_port", label: "Webhook Port", type: "number", defaultValue: 3000, help: "Webhook mode only" }, - { key: "webhook_path", label: "Webhook Path", type: "text", defaultValue: "/feishu/events", help: "Webhook mode only" }, + { key: "webhook_port", label: "Webhook Port", type: "number", defaultValue: 0, help: "0 = share main gateway port (recommended)" }, + { key: "webhook_path", label: "Webhook Path", type: "text", defaultValue: "/feishu/events", help: "Path on main server for Lark events" }, { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" }, { key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "open" }, { key: "require_mention", label: "Require @mention in groups", type: "boolean", defaultValue: true },