feat: Mount channel webhook handlers directly on the main gateway.

This commit is contained in:
viettranx committed 2026-03-03 10:32:29 +07:00
1 parent 18c783d1db
commit 6e7301ed94
5 files changed
+85 -7

No files matched your search

+8
View File
@@ -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()
+12
View File
@@ -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).
+39 -5
View File
@@ -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)
+24
View File
@@ -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()
+2 -2
View File
@@ -77,8 +77,8 @@ export const configSchema: Record<string, FieldDef[]> = {
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 },