From 617157262bdd691036fb6bac55693e4a2c722958 Mon Sep 17 00:00:00 2001 From: Aska Date: Mon, 7 Sep 2026 14:10:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel):=20KOOK=20Webhook(WH)=20=E6=8E=A5?= =?UTF-8?q?=E6=94=B6=E6=A8=A1=E5=BC=8F=E2=80=94=E2=80=94=E4=B8=8E=20WS=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=88=87=E6=8D=A2(ReceiveMode)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - kook.Config 加 ReceiveMode("ws"默认/"webhook") + WebhookPath(默认/webhook/kook) + WebhookVerifyToken + WebhookEncryptKey(AES-256-CBC) - 按权威文档(Reference/kaiheila webhook.md)实现: 无X-Signature HMAC, 真实校验=d.verify_token比对(不匹配403)+可选AES解密+zlib解压+challenge回显 - webhook.go: InboundHandler+WebhookPath, POST→zlib解压→AES解密→verify_token校验→challenge回显→归一UnifiedMessage→SetMessageHandler; WS模式Connect不拨网关仅标记connected - channelbridge/webhookAdapter 接口改用 WebhookPath(); WebhookHandlers 收KOOK WH(ws模式跳过); 既有webhook通道补WebhookPath照常挂载 - namedAdapter 转发InboundHandler/WebhookPath; kook env兜底 +4个CHANNEL_KOOK_*键 - 测试: verify_token 403/challenge/zlib/AES往返/WebhookPath按模式/WH不拨gateway/WebhookHandlers收KOOK; 全仓build/vet/test绿 - 待真token端到端实测(需主人提供CHANNEL_KOOK_TOKEN+开发者后台切WebHook配回调URL) --- .env.example | 10 + cmd/cyrene/channels.go | 28 +- cmd/cyrene/channels_test.go | 46 +++ .../services/channelbridge/channelbridge.go | 28 +- .../channelbridge/channelbridge_test.go | 56 ++- plugins/channels/kook/adapter.go | 43 ++- plugins/channels/kook/config.go | 53 ++- plugins/channels/kook/config_test.go | 67 ++++ plugins/channels/kook/webhook.go | 314 +++++++++++++++ plugins/channels/kook/webhook_test.go | 363 ++++++++++++++++++ plugins/channels/webhook/adapter.go | 5 + 11 files changed, 981 insertions(+), 32 deletions(-) create mode 100644 plugins/channels/kook/webhook.go create mode 100644 plugins/channels/kook/webhook_test.go diff --git a/.env.example b/.env.example index 79ab811..a5d52c8 100644 --- a/.env.example +++ b/.env.example @@ -101,8 +101,18 @@ ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 # 单账号走环境变量;多账号走下方 CHANNELS_JSON。 # CHANNEL_KOOK_TOKEN 为 KOOK 开发者中心机器人 token(敏感,勿写日志/提交)。 # CHANNEL_KOOK_BASE_URL 可选,默认 https://www.kookapp.cn/api/v3。 +# CHANNEL_KOOK_RECEIVE_MODE 可选:ws(默认,连 KOOK gateway WebSocket 收事件)| webhook +# (在 Cyrene gateway 挂一条路由接收 KOOK 的 POST 回调;webhook 与 ws 互斥,需在 KOOK +# 开发者后台把机器人连接模式切到 WebHook 并填回调 URL)。 +# CHANNEL_KOOK_WEBHOOK_PATH 可选,webhook 模式回调路由,默认 /webhook/kook。 +# CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN 可选,KOOK 开发者后台 verify token(回调校验,不匹配 403)。 +# CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY 可选,KOOK Encrypt Key(回调 AES-256-CBC 解密)。 # CHANNEL_KOOK_TOKEN= # CHANNEL_KOOK_BASE_URL= +# CHANNEL_KOOK_RECEIVE_MODE=ws +# CHANNEL_KOOK_WEBHOOK_PATH= +# CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN= +# CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY= # ---- IM 多账号 / 黑白名单(可选,design/16 §4.6 §5-6) ---- # ⚠️ 本节是【兜底】:管理面板「渠道管理」页保存会写入本地 channels.json(默认 /channels.json,已 gitignore), diff --git a/cmd/cyrene/channels.go b/cmd/cyrene/channels.go index bdb26b5..5c9f0b1 100644 --- a/cmd/cyrene/channels.go +++ b/cmd/cyrene/channels.go @@ -17,6 +17,7 @@ package main import ( "fmt" "log/slog" + "net/http" "os" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" @@ -91,8 +92,12 @@ func buildPlatformAdapters(platform string, accts []config.ChannelAccount, log * func channelAccountsFromEnv() map[string][]config.ChannelAccount { out := map[string][]config.ChannelAccount{} out["kook"] = append(out["kook"], config.ChannelAccount{Config: map[string]any{ - "bot_token": os.Getenv("CHANNEL_KOOK_TOKEN"), - "base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"), + "bot_token": os.Getenv("CHANNEL_KOOK_TOKEN"), + "base_url": os.Getenv("CHANNEL_KOOK_BASE_URL"), + "receive_mode": os.Getenv("CHANNEL_KOOK_RECEIVE_MODE"), + "webhook_path": os.Getenv("CHANNEL_KOOK_WEBHOOK_PATH"), + "webhook_verify_token": os.Getenv("CHANNEL_KOOK_WEBHOOK_VERIFY_TOKEN"), + "webhook_encrypt_key": os.Getenv("CHANNEL_KOOK_WEBHOOK_ENCRYPT_KEY"), }}) out["telegram"] = append(out["telegram"], config.ChannelAccount{Config: map[string]any{ "bot_token": os.Getenv("CHANNEL_TELEGRAM_BOT_TOKEN"), @@ -166,6 +171,25 @@ func (n *namedAdapter) Config() map[string]any { return out } +// InboundHandler forwards to the inner adapter when it exposes an inbound HTTP +// callback; the bridge type-asserts the wrapper and mounts the handler at +// WebhookPath. It returns nil for a non-webhook inner adapter. +func (n *namedAdapter) InboundHandler() http.Handler { + if inner, ok := n.PlatformAdapter.(interface{ InboundHandler() http.Handler }); ok { + return inner.InboundHandler() + } + return nil +} + +// WebhookPath forwards the inner adapter's webhook route, returning "" when the +// inner adapter does not expose one (so the bridge skips it). +func (n *namedAdapter) WebhookPath() string { + if inner, ok := n.PlatformAdapter.(interface{ WebhookPath() string }); ok { + return inner.WebhookPath() + } + return "" +} + // buildAccountAdapter builds one account's adapter for a platform, decoding the // account's generic Config map into the platform's typed config and wrapping it so // the bridge sees a distinct account name and its allow/deny lists. diff --git a/cmd/cyrene/channels_test.go b/cmd/cyrene/channels_test.go index 63f90cc..4e03e1b 100644 --- a/cmd/cyrene/channels_test.go +++ b/cmd/cyrene/channels_test.go @@ -97,6 +97,52 @@ func TestChannelFactoryBuildFromFile(t *testing.T) { } } +// TestNamedAdapterForwardsWebhook verifies the production wrapper forwards a +// kook webhook-mode adapter's InboundHandler/WebhookPath to the channel bridge, +// so bridge.WebhookHandlers() mounts the KOOK callback route on the gateway. +func TestNamedAdapterForwardsWebhook(t *testing.T) { + t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret") + t.Setenv("CHANNEL_KOOK_RECEIVE_MODE", "webhook") + t.Setenv("CHANNEL_KOOK_WEBHOOK_PATH", "/webhook/kook") + + adapters := buildChannelAdapters(testFactory(t)) + var found *namedAdapter + for _, a := range adapters { + if nwa, ok := a.(*namedAdapter); ok && barePlatform(nwa.Name()) == "kook" { + found = nwa + break + } + } + if found == nil { + t.Fatalf("kook named adapter not found, got %v", names(adapters)) + } + if p := found.WebhookPath(); p != "/webhook/kook" { + t.Errorf("WebhookPath = %q, want /webhook/kook", p) + } + if found.InboundHandler() == nil { + t.Error("InboundHandler() = nil, want a mounted handler") + } +} + +// TestNamedAdapterNoWebhookInWSMode verifies a ws-mode adapter (all non-webhook +// platforms) does not surface a webhook handler through the wrapper. +func TestNamedAdapterNoWebhookInWSMode(t *testing.T) { + t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret") + t.Setenv("CHANNEL_KOOK_RECEIVE_MODE", "ws") + + adapters := buildChannelAdapters(testFactory(t)) + for _, a := range adapters { + if barePlatform(a.Name()) != "kook" { + continue + } + if nwa, ok := a.(*namedAdapter); ok { + if p := nwa.WebhookPath(); p != "" { + t.Errorf("ws-mode kook WebhookPath = %q, want empty", p) + } + } + } +} + func names(adapters []adapter.PlatformAdapter) []string { out := make([]string, 0, len(adapters)) for _, a := range adapters { diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index 94d35be..8086cf3 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -31,15 +31,16 @@ import ( ) // 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. +// source an HTTP inbound callback. *webhook.Adapter and *kook.Adapter (webhook +// mode) implement it. 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 returns the http.Handler the gateway mounts at the route 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 + // WebhookPath returns the route the gateway should mount the handler at. It + // returns "" when the adapter is not currently exposing a webhook, letting the + // bridge skip it (e.g. a websocket-mode KOOK). + WebhookPath() string } // Knob constants. Compile-time so no bare magic number appears in the run path @@ -229,12 +230,17 @@ func (b *Bridge) WebhookHandlers() map[string]http.Handler { if !ok { continue } - cfg := wh.Config() - path, _ := cfg["endpointPath"].(string) + path := wh.WebhookPath() if path == "" { - path = "/webhook" + // Adapter is not currently exposing a webhook (e.g. a websocket-mode + // KOOK); skipping avoids mounting a handler at a stale or colliding route. + continue } - out[path] = wh.InboundHandler() + h := wh.InboundHandler() + if h == nil { + continue + } + out[path] = h } if len(out) == 0 { return nil diff --git a/internal/services/channelbridge/channelbridge_test.go b/internal/services/channelbridge/channelbridge_test.go index e590c8b..367293f 100644 --- a/internal/services/channelbridge/channelbridge_test.go +++ b/internal/services/channelbridge/channelbridge_test.go @@ -12,6 +12,8 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/kook" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/channels/webhook" ) // fakeAdapter is a minimal PlatformAdapter that records outbound sends so the @@ -398,7 +400,7 @@ func TestBlacklistGroupMessageDroppedBeforeLoop(t *testing.T) { // A stranger's group session that would otherwise be processed. sess := &stubSessions{sess: session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}} ad := &fakeAdapter{name: "telegram", cfg: map[string]any{ - "bot_token": "configured", + "bot_token": "configured", "denyGroups": []string{"-100"}, }} var rejected []string @@ -438,7 +440,7 @@ func TestBlacklistUserMessageDroppedBeforeLoop(t *testing.T) { chat := &stubChatter{res: &loop.Result{Text: "不该回复"}} sess := &stubSessions{sess: session.Session{SessionID: "sess-s", IsGroup: false, Owner: false, MemWritable: false}} ad := &fakeAdapter{name: "telegram", cfg: map[string]any{ - "bot_token": "configured", + "bot_token": "configured", "allowUsers": []string{"111"}, }} b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: &captureHistory{}}) @@ -555,3 +557,53 @@ func TestNoHistoryStoreKeepsBridgeUnchanged(t *testing.T) { t.Fatalf("loop session id = %q, want %q", chat.sessionID, history.MainSessionID) } } + +// newWebhookBridge builds a bare bridge (no adapters) wired with the stubs the +// other tests use, so a WebhookHandlers test can add adapters explicitly. +func newWebhookBridge(t *testing.T) *Bridge { + t.Helper() + chat := &stubChatter{res: &loop.Result{Text: "hi"}} + sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}} + b, err := New(Options{Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}}) + if err != nil { + t.Fatalf("New: %v", err) + } + return b +} + +func TestWebhookHandlersCollectsKookWebhook(t *testing.T) { + t.Parallel() + + b := newWebhookBridge(t) + b.Add(kook.New(kook.Config{BotToken: "t", ReceiveMode: "webhook", WebhookPath: "/webhook/kook"})) + + handlers := b.WebhookHandlers() + h, ok := handlers["/webhook/kook"] + if !ok || h == nil { + t.Fatalf("kook webhook handler not mounted; got %v", handlers) + } +} + +func TestWebhookHandlersSkipsWSModeKook(t *testing.T) { + t.Parallel() + + b := newWebhookBridge(t) + b.Add(kook.New(kook.Config{BotToken: "t", ReceiveMode: "ws"})) + + if got := b.WebhookHandlers(); got != nil { + t.Fatalf("ws-mode kook exposed a webhook handler, got %v", got) + } +} + +func TestWebhookHandlersMountsWebhookChannel(t *testing.T) { + t.Parallel() + + b := newWebhookBridge(t) + b.Add(webhook.New(webhook.WithConfig(webhook.Config{EndpointPath: "/cb"}))) + + handlers := b.WebhookHandlers() + h, ok := handlers["/cb"] + if !ok || h == nil { + t.Fatalf("webhook channel handler not mounted; got %v", handlers) + } +} diff --git a/plugins/channels/kook/adapter.go b/plugins/channels/kook/adapter.go index 57d7a05..2314f96 100644 --- a/plugins/channels/kook/adapter.go +++ b/plugins/channels/kook/adapter.go @@ -112,12 +112,18 @@ func New(cfg Config, opts ...Option) *Adapter { // Name returns the platform identifier. func (a *Adapter) Name() string { return platformName } -// Config returns a snapshot of the adapter's configuration. The BotToken is the -// sensitive field; callers that persist this map must treat it as a secret. +// Config returns a snapshot of the adapter's configuration. The BotToken, +// WebhookVerifyToken and WebhookEncryptKey are sensitive; callers that persist +// this map must treat them as secrets (the admin read path masks these keys via +// the "token"/"key" substring rules). func (a *Adapter) Config() map[string]any { return map[string]any{ - "bot_token": a.cfg.BotToken, - "base_url": a.baseURL, + "bot_token": a.cfg.BotToken, + "base_url": a.baseURL, + "receive_mode": a.cfg.normalizedReceiveMode(), + "webhook_path": a.cfg.WebhookPath, + "webhook_verify_token": a.cfg.WebhookVerifyToken, + "webhook_encrypt_key": a.cfg.WebhookEncryptKey, } } @@ -138,8 +144,21 @@ func (a *Adapter) Connect(ctx context.Context) error { a.mu.Unlock() return err } + mode := a.cfg.normalizedReceiveMode() a.mu.Unlock() + // Webhook mode: the Cyrene gateway mounts InboundHandler to receive KOOK + // callbacks (KOOK posts to the configured route). No gateway WebSocket is + // dialed — webhook and websocket are mutually exclusive. Mark connected so the + // REST reply path and HealthCheck see a live session. + if mode == modeWebhook { + a.mu.Lock() + a.connected = true + a.mu.Unlock() + a.log.Info("connected", "mode", mode, "token_len", len(a.cfg.BotToken)) + return nil + } + if _, err := a.fetchGatewayURL(ctx); err != nil { return fmt.Errorf("kook: connect: %w", err) } @@ -151,7 +170,7 @@ func (a *Adapter) Connect(ctx context.Context) error { a.mu.Unlock() go a.runWS(lifeCtx) - a.log.Info("connected", "token_len", len(a.cfg.BotToken)) + a.log.Info("connected", "mode", mode, "token_len", len(a.cfg.BotToken)) return nil } @@ -230,14 +249,14 @@ func (a *Adapter) SetMessageHandler(fn adapter.MessageHandler) { // bot-uploaded media URLs. func (a *Adapter) Capabilities() adapter.Capabilities { return adapter.Capabilities{ - MaxMessageLength: maxMessageLen, - Markdown: true, - Image: false, - Voice: false, - Reaction: false, - TypingHint: false, + MaxMessageLength: maxMessageLen, + Markdown: true, + Image: false, + Voice: false, + Reaction: false, + TypingHint: false, RecommendBurstMax: 1, - Proactive: true, + Proactive: true, }.Normalize() } diff --git a/plugins/channels/kook/config.go b/plugins/channels/kook/config.go index fbe9a62..86761dd 100644 --- a/plugins/channels/kook/config.go +++ b/plugins/channels/kook/config.go @@ -1,7 +1,9 @@ // Package kook implements the built-in KOOK (KaiHeiLa) channel adapter for -// Cyrene v2. KOOK is a stateful WebSocket-gateway channel: inbound events are -// delivered over a gateway WebSocket (hello/heartbeat/event/resume signaling), -// while outbound messages are sent over a bearer-authenticated REST API. +// Cyrene v2. KOOK is a stateful channel with two mutually exclusive inbound +// modes: a WebSocket-gateway mode (events over a gateway WebSocket with +// hello/heartbeat/event/resume signaling) and a Webhook mode (KOOK POSTs event +// callbacks to a Cyrene gateway route). Outbound messages are always sent over a +// bearer-authenticated REST API. // // The adapter conforms to internal/channel/adapter.PlatformAdapter, translating // native KOOK events into the normalized adapter.UnifiedMessage model and @@ -13,7 +15,7 @@ // The design authority is DevDocs/design/08-渠道接入.md §4 (kook) and §7 // (framework reusability). It depends on internal/channel/adapter (including the // shared wsutil and httpclient primitives), internal/logger, and the standard -// library. The bot token is never logged. +// library. The bot token, verify token and encrypt key are never logged. package kook import ( @@ -37,6 +39,21 @@ const ( helloTimeout = 6 * time.Second ) +// Inbound receive modes. KOOK's webhook and websocket modes are mutually +// exclusive (DevDocs/design/08 §4): selecting webhook means the gateway WebSocket +// no longer delivers events, and vice versa. ReceiveMode decides which path the +// adapter uses; the two never run together. +const ( + // modeWS is the default: connect the KOOK gateway WebSocket and receive events. + modeWS = "ws" + // modeWebhook exposes a Cyrene gateway route that KOOK POSTs callbacks to. + modeWebhook = "webhook" + // defaultWebhookPath is used when Config.WebhookPath is empty in webhook mode. + defaultWebhookPath = "/webhook/kook" + // defaultMaxBodyBytes caps an inbound webhook payload at 1 MiB. + defaultMaxBodyBytes = 1 << 20 +) + // Config holds the settings required to reach the KOOK API. type Config struct { // BotToken is the secret bot token issued by the KOOK developer center. It is @@ -44,9 +61,35 @@ type Config struct { BotToken string `json:"bot_token"` // BaseURL is the KOOK REST API root; empty uses the production default. BaseURL string `json:"base_url"` + // ReceiveMode selects the inbound delivery path: "ws" (default) connects the + // KOOK gateway WebSocket; "webhook" exposes a gateway route that KOOK POSTs + // events to. The two are mutually exclusive. + ReceiveMode string `json:"receive_mode"` + // WebhookPath is the route the Cyrene gateway mounts the KOOK webhook handler + // at when ReceiveMode == "webhook". Empty uses defaultWebhookPath. + WebhookPath string `json:"webhook_path"` + // WebhookVerifyToken is the KOOK developer-center verify token. When set, every + // webhook callback's d.verify_token must match or it is rejected with 403. + WebhookVerifyToken string `json:"webhook_verify_token"` + // WebhookEncryptKey is the KOOK Encrypt Key. When set, an encrypted callback + // body ({"encrypt": ...}) is decrypted with AES-256-CBC before parsing. + WebhookEncryptKey string `json:"webhook_encrypt_key"` } -// Validate checks the configuration for values needed to connect. +// normalizedReceiveMode returns ReceiveMode, defaulting empty to "ws". +func (c Config) normalizedReceiveMode() string { + if strings.TrimSpace(c.ReceiveMode) == "" { + return modeWS + } + return strings.TrimSpace(c.ReceiveMode) +} + +// IsWebhook reports whether the adapter should run in webhook mode. +func (c Config) IsWebhook() bool { return c.normalizedReceiveMode() == modeWebhook } + +// Validate checks the configuration for values needed to connect. The bot token +// is required in both modes because outbound (reply) delivery always uses the +// bearer REST API. func (c Config) Validate() error { if strings.TrimSpace(c.BotToken) == "" { return ErrMissingToken diff --git a/plugins/channels/kook/config_test.go b/plugins/channels/kook/config_test.go index 22e7aad..1991755 100644 --- a/plugins/channels/kook/config_test.go +++ b/plugins/channels/kook/config_test.go @@ -113,3 +113,70 @@ func TestSendMessage_NotConnected(t *testing.T) { t.Fatalf("SendProactive err = %v, want ErrNotConnected", err) } } + +func TestNormalizedReceiveMode(t *testing.T) { + t.Parallel() + if got := (Config{}).normalizedReceiveMode(); got != modeWS { + t.Fatalf("empty ReceiveMode = %q, want %q", got, modeWS) + } + if got := (Config{ReceiveMode: modeWebhook}).normalizedReceiveMode(); got != modeWebhook { + t.Fatalf("webhook ReceiveMode = %q, want %q", got, modeWebhook) + } + if got := (Config{ReceiveMode: " ws "}).normalizedReceiveMode(); got != modeWS { + t.Fatalf("trimmed ReceiveMode = %q, want %q", got, modeWS) + } + if (Config{}).IsWebhook() { + t.Error("empty ReceiveMode should not be webhook") + } + if !(Config{ReceiveMode: modeWebhook}).IsWebhook() { + t.Error("webhook ReceiveMode should be webhook") + } +} + +func TestConfigReceiveModeSnapshotAndRoundTrip(t *testing.T) { + t.Parallel() + a := New(Config{ + BotToken: "abc123", + ReceiveMode: modeWebhook, + WebhookPath: "/cb", + WebhookVerifyToken: "vt-1", + WebhookEncryptKey: "ek-1", + }) + got := a.Config() + if got["receive_mode"] != modeWebhook { + t.Errorf("receive_mode = %v, want %q", got["receive_mode"], modeWebhook) + } + if got["webhook_path"] != "/cb" { + t.Errorf("webhook_path = %v, want /cb", got["webhook_path"]) + } + if got["webhook_verify_token"] != "vt-1" || got["webhook_encrypt_key"] != "ek-1" { + t.Errorf("webhook secrets = %v/%v, want vt-1/ek-1", got["webhook_verify_token"], got["webhook_encrypt_key"]) + } + + var round Config + if err := adapter.DecodeConfig(got, &round); err != nil { + t.Fatalf("DecodeConfig: %v", err) + } + if round.ReceiveMode != modeWebhook || round.WebhookPath != "/cb" || + round.WebhookVerifyToken != "vt-1" || round.WebhookEncryptKey != "ek-1" { + t.Fatalf("round trip mismatch: %+v", round) + } +} + +func TestConfigDefaultReceiveModeSnapshot(t *testing.T) { + t.Parallel() + a := New(Config{BotToken: "x"}) + if got := a.Config()["receive_mode"]; got != modeWS { + t.Errorf("default receive_mode = %v, want %q", got, modeWS) + } +} + +func TestValidateRequiresTokenBothModes(t *testing.T) { + t.Parallel() + if err := (Config{BotToken: "t", ReceiveMode: modeWebhook}).Validate(); err != nil { + t.Fatalf("webhook mode with token Validate = %v, want nil", err) + } + if err := (Config{ReceiveMode: modeWebhook}).Validate(); !errors.Is(err, ErrMissingToken) { + t.Fatalf("webhook mode without token Validate = %v, want ErrMissingToken", err) + } +} diff --git a/plugins/channels/kook/webhook.go b/plugins/channels/kook/webhook.go new file mode 100644 index 0000000..47f5f42 --- /dev/null +++ b/plugins/channels/kook/webhook.go @@ -0,0 +1,314 @@ +package kook + +// This file implements the KOOK webhook inbound mode (DevDocs/design/08 §4). +// When ReceiveMode == "webhook" the adapter does not connect the gateway +// WebSocket; instead the Cyrene gateway mounts InboundHandler at WebhookPath and +// KOOK POSTs event callbacks to it (KOOK's webhook and websocket modes are +// mutually exclusive). +// +// KOOK webhook protocol (docs/zh-cn/webhook.md): the callback body is event JSON +// {s,d,sn} — zlib-compressed by default unless the callback URL carries +// compress=0. Optionally the whole body is a {"encrypt": ...} AES-256-CBC +// wrapper. A challenge request (d.type=255, d.channel_type=WEBHOOK_CHALLENGE, +// d.challenge=...) must be answered within 1s by echoing {challenge}. Event +// authenticity is proven by comparing d.verify_token to the developer-center +// verify token; a mismatch is rejected with 403. There is no header-based HMAC +// signature for KOOK webhook — verification is the in-body verify_token plus +// optional AES encryption. +// +// Decay of the same path as the WebSocket events: the parsed event d payload is +// normalized through toUnifiedMessage and dispatched via SetMessageHandler/emit, +// so inbound ordering, actor metadata and group/private routing are identical for +// both modes. + +import ( + "bytes" + "compress/zlib" + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +// webhookChallengeChannel is KOOK's marker for a challenge request. +const webhookChallengeChannel = "WEBHOOK_CHALLENGE" + +// errVerifyTokenMismatch is returned when an event's d.verify_token does not +// match the configured WebhookVerifyToken. Callers map it to HTTP 403. +var errVerifyTokenMismatch = errors.New("kook: webhook verify token mismatch") + +// InboundHandler returns the http.Handler the gateway mounts at WebhookPath to +// receive KOOK webhook callbacks. It is safe to serve concurrently once built +// from the adapter's immutable config. +func (a *Adapter) InboundHandler() http.Handler { + return http.HandlerFunc(a.serveWebhook) +} + +// WebhookPath returns the route to mount when ReceiveMode == "webhook", else "" +// so the channel bridge skips the adapter (a websocket-mode KOOK exposes no +// inbound HTTP handler). +func (a *Adapter) WebhookPath() string { + if !a.cfg.IsWebhook() { + return "" + } + if a.cfg.WebhookPath != "" { + return a.cfg.WebhookPath + } + return defaultWebhookPath +} + +// serveWebhook is the HTTP entry point for a KOOK callback: it validates the +// method, decompresses/decrypts, verifies the token, and either answers a +// challenge or normalizes the event into a UnifiedMessage. +func (a *Adapter) serveWebhook(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeWebhookError(w, http.StatusMethodNotAllowed, "method_not_allowed", + "kook: webhook method not allowed") + return + } + + body, err := readWebhookBody(w, r) + if err != nil { + writeWebhookError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + payload, err := a.decodeWebhookPayload(body) + if err != nil { + writeWebhookError(w, http.StatusBadRequest, "bad_request", err.Error()) + return + } + + challenge, err := a.handleWebhookEvent(r.Context(), payload) + if err != nil { + status, code := http.StatusBadRequest, "bad_request" + if errors.Is(err, errVerifyTokenMismatch) { + // Verify-token failure is a security rejection: 403, not 400. + status, code = http.StatusForbidden, "forbidden" + } + writeWebhookError(w, status, code, err.Error()) + return + } + + if challenge != "" { + writeWebhookChallenge(w, challenge) + return + } + writeWebhookOK(w) +} + +// readWebhookBody reads and size-caps the request body so a misconfigured caller +// cannot exhaust memory. +func readWebhookBody(w http.ResponseWriter, r *http.Request) ([]byte, error) { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, defaultMaxBodyBytes)) + if err != nil { + return nil, fmt.Errorf("kook: webhook read body: %w", err) + } + return body, nil +} + +// decodeWebhookPayload returns the JSON event payload from a callback body. It +// first zlib-decompresses when the body carries the zlib header (KOOK compresses +// by default), then AES-256-CBC-decrypts when an encrypt wrapper is present and +// an encrypt key is configured. +func (a *Adapter) decodeWebhookPayload(body []byte) ([]byte, error) { + payload := body + if isZlib(payload) { + dec, err := zlibDecode(payload) + if err != nil { + return nil, fmt.Errorf("kook: webhook decompress: %w", err) + } + payload = dec + } + if a.cfg.WebhookEncryptKey != "" { + if enc, ok := encryptWrapper(payload); ok { + dec, err := decryptKOOK(enc, a.cfg.WebhookEncryptKey) + if err != nil { + return nil, fmt.Errorf("kook: webhook decrypt: %w", err) + } + payload = dec + } + } + if len(payload) == 0 { + return nil, errors.New("kook: webhook empty body") + } + return payload, nil +} + +// handleWebhookEvent verifies the token, answers a challenge when present, and +// otherwise normalizes the event into a UnifiedMessage and dispatches it. It +// returns the challenge value (to be echoed) or "" for a processed event. +func (a *Adapter) handleWebhookEvent(ctx context.Context, payload []byte) (string, error) { + var env kookSignal + if err := json.Unmarshal(payload, &env); err != nil { + return "", fmt.Errorf("kook: webhook decode envelope: %w", err) + } + if err := a.verifyWebhookToken(env.D); err != nil { + return "", err + } + + var ch struct { + Challenge string `json:"challenge"` + } + if json.Unmarshal(env.D, &ch) == nil && ch.Challenge != "" { + return ch.Challenge, nil + } + + msg, ok := a.toUnifiedMessage(env.D, payload) + if !ok { + // Non-chat notification (system, etc.): acknowledge without a reply. + return "", nil + } + if err := msg.Validate(); err != nil { + a.log.Warn("invalid webhook message dropped", "err", err) + return "", nil + } + a.dispatch(ctx, msg) + return "", nil +} + +// verifyWebhookToken checks d.verify_token against the configured token when one +// is set; an empty configured token disables verification. A mismatch (or a +// payload that omits verify_token while verification is enabled) is rejected. +func (a *Adapter) verifyWebhookToken(d json.RawMessage) error { + want := a.cfg.WebhookVerifyToken + if want == "" { + return nil + } + var v struct { + VerifyToken string `json:"verify_token"` + } + if err := json.Unmarshal(d, &v); err != nil { + return fmt.Errorf("kook: webhook token read: %w", err) + } + if v.VerifyToken != want { + return fmt.Errorf("%w", errVerifyTokenMismatch) + } + return nil +} + +// isZlib reports whether b begins with a zlib header (0x78), the KOOK default +// compression framing. JSON payloads start with '{' (0x7b), so this never +// misclassifies a plain callback body. +func isZlib(b []byte) bool { + return len(b) >= 3 && b[0] == 0x78 +} + +// zlibDecode inflates a zlib stream. +func zlibDecode(b []byte) ([]byte, error) { + zr, err := zlib.NewReader(bytes.NewReader(b)) + if err != nil { + return nil, err + } + defer func() { _ = zr.Close() }() + out, err := io.ReadAll(zr) + if err != nil { + return nil, err + } + return out, nil +} + +// encryptWrapper returns the encrypted blob when the body is the {"encrypt": ...} +// wrapper KOOK uses when message encryption is enabled. +func encryptWrapper(payload []byte) (string, bool) { + var w struct { + Encrypt string `json:"encrypt"` + } + if err := json.Unmarshal(payload, &w); err != nil || w.Encrypt == "" { + return "", false + } + return w.Encrypt, true +} + +// decryptKOOK decrypts an encrypted KOOK callback body. The encrypt value is +// base64(iv || base64(ciphertext)); the key is the Encrypt Key right-padded with +// NUL to 32 bytes. The cipher is AES-256-CBC with PKCS7 padding. +func decryptKOOK(encrypt, key string) ([]byte, error) { + raw, err := base64.StdEncoding.DecodeString(encrypt) + if err != nil { + return nil, err + } + if len(raw) <= aes.BlockSize { + return nil, errors.New("kook: webhook encrypted body too short") + } + iv := raw[:aes.BlockSize] + ct, err := base64.StdEncoding.DecodeString(string(raw[aes.BlockSize:])) + if err != nil { + return nil, err + } + if len(ct) == 0 || len(ct)%aes.BlockSize != 0 { + return nil, errors.New("kook: webhook ciphertext length not a block multiple") + } + block, err := aes.NewCipher(padKey(key, 32)) + if err != nil { + return nil, err + } + plain := make([]byte, len(ct)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ct) + return pkcs7Unpad(plain) +} + +// padKey returns key as a right-NUL-padded byte slice of exactly size bytes, +// truncating an over-long key to size. +func padKey(key string, size int) []byte { + out := make([]byte, 0, size) + for i := 0; i < len(key) && i < size; i++ { + out = append(out, key[i]) + } + for len(out) < size { + out = append(out, 0) + } + return out +} + +// pkcs7Unpad strips the PKCS7 padding from decryptKOOK's output. +func pkcs7Unpad(in []byte) ([]byte, error) { + if len(in) == 0 { + return nil, errors.New("kook: webhook empty plaintext") + } + padding := int(in[len(in)-1]) + if padding == 0 || padding > aes.BlockSize || padding > len(in) { + return nil, errors.New("kook: webhook invalid padding") + } + for i := len(in) - padding; i < len(in); i++ { + if int(in[i]) != padding { + return nil, errors.New("kook: webhook invalid padding") + } + } + return in[:len(in)-padding], nil +} + +// webhookChallengeBody is the response KOOK expects for a challenge request. +type webhookChallengeBody struct { + Challenge string `json:"challenge"` +} + +// writeWebhookChallenge echoes the challenge value within the 1s window KOOK +// allows for the handshake. +func writeWebhookChallenge(w http.ResponseWriter, challenge string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(webhookChallengeBody{Challenge: challenge}) +} + +// writeWebhookOK acknowledges a processed event with HTTP 200 within the 1s +// window (KOOK retries up to 5 times on failure). +func writeWebhookOK(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +// writeWebhookError writes a JSON error envelope with the given status/code. +func writeWebhookError(w http.ResponseWriter, status int, code, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{"code": code, "message": msg}, + }) +} diff --git a/plugins/channels/kook/webhook_test.go b/plugins/channels/kook/webhook_test.go new file mode 100644 index 0000000..be65a93 --- /dev/null +++ b/plugins/channels/kook/webhook_test.go @@ -0,0 +1,363 @@ +package kook + +import ( + "bytes" + "compress/zlib" + "context" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" +) + +// kookEventBody builds a KOOK s=0 message-event payload (channel_type GROUP) +// with the given content and verify token, matching the WebSocket event shape so +// the webhook path normalizes it identically. +func kookEventBody(content, verifyToken string) []byte { + extra := map[string]any{ + "type": 1, + "guild_id": "g-8", + "channel_name": "test", + "mention": []string{}, + "author": map[string]any{ + "id": "u-8", "username": "u", "nickname": "Nick", + }, + } + d := map[string]any{ + "channel_type": "GROUP", + "type": 1, + "target_id": "chan-8", + "author_id": "u-8", + "content": content, + "msg_id": "m-8", + "msg_timestamp": 1700000000000, + "nonce": "", + "extra": extra, + } + if verifyToken != "" { + d["verify_token"] = verifyToken + } + body, _ := json.Marshal(map[string]any{"s": 0, "sn": 7, "d": d}) + return body +} + +func webhookAdapter(t *testing.T, cfg Config, mh adapter.MessageHandler) *Adapter { + t.Helper() + a := New(cfg, WithLogger(discardLogger())) + if mh != nil { + a.SetMessageHandler(mh) + } + return a +} + +func TestWebhookPathByMode(t *testing.T) { + t.Parallel() + + ws := New(Config{BotToken: "t", ReceiveMode: modeWS}) + if p := ws.WebhookPath(); p != "" { + t.Errorf("ws mode WebhookPath() = %q, want empty", p) + } + + wh := New(Config{BotToken: "t", ReceiveMode: modeWebhook}) + if p := wh.WebhookPath(); p != defaultWebhookPath { + t.Errorf("webhook mode WebhookPath() = %q, want %q", p, defaultWebhookPath) + } + + custom := New(Config{BotToken: "t", ReceiveMode: modeWebhook, WebhookPath: "/cb"}) + if p := custom.WebhookPath(); p != "/cb" { + t.Errorf("custom WebhookPath() = %q, want /cb", p) + } +} + +func TestWebhookHandlerDeliversEvent(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + delivered []adapter.UnifiedMessage + ) + mh := func(_ context.Context, msg adapter.UnifiedMessage) { + mu.Lock() + delivered = append(delivered, msg) + mu.Unlock() + } + a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook, WebhookVerifyToken: "vt-123"}, mh) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, + bytes.NewReader(kookEventBody("webhook hi", "vt-123"))) + req.Header.Set("Content-Type", "application/json") + a.InboundHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + + mu.Lock() + defer mu.Unlock() + if len(delivered) != 1 { + t.Fatalf("delivered %d messages, want 1", len(delivered)) + } + msg := delivered[0] + if msg.Platform != "kook" || msg.ChannelID != "chan-8" || msg.GroupID != "g-8" { + t.Errorf("identity = %q/%q/%q, want kook/chan-8/g-8", msg.Platform, msg.ChannelID, msg.GroupID) + } + if msg.SenderName != "Nick" || msg.SenderID != "u-8" { + t.Errorf("sender = %q/%q, want Nick/u-8", msg.SenderName, msg.SenderID) + } + if msg.Content != "webhook hi" || msg.ContentType != adapter.ContentText { + t.Errorf("content = %q/%q, want webhook hi/text", msg.Content, msg.ContentType) + } +} + +func TestWebhookHandlerChallenge(t *testing.T) { + t.Parallel() + + a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook, WebhookVerifyToken: "vt-123"}, nil) + + challenge := "bkes654x09XY" + body, _ := json.Marshal(map[string]any{ + "s": 0, + "d": map[string]any{ + "type": 255, + "channel_type": webhookChallengeChannel, + "challenge": challenge, + "verify_token": "vt-123", + }, + }) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, bytes.NewReader(body)) + a.InboundHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("challenge status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + var out struct { + Challenge string `json:"challenge"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("decode challenge response: %v", err) + } + if out.Challenge != challenge { + t.Errorf("challenge echo = %q, want %q", out.Challenge, challenge) + } +} + +func TestWebhookHandlerVerifyTokenMismatch403(t *testing.T) { + t.Parallel() + + a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook, WebhookVerifyToken: "vt-123"}, nil) + + body := kookEventBody("spoofed", "wrong-token") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, bytes.NewReader(body)) + a.InboundHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestWebhookHandlerVerifyTokenDisabledWhenUnset(t *testing.T) { + t.Parallel() + + a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook}, nil) + + // No token configured → any callback is accepted regardless of its (absent) + // verify_token. The event is still normalized; the handler is nil so it is + // dropped without an error. + body := kookEventBody("hi", "") + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, bytes.NewReader(body)) + a.InboundHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } +} + +func TestWebhookHandlerZlibCompressed(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + delivered []adapter.UnifiedMessage + ) + a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook}, func(_ context.Context, msg adapter.UnifiedMessage) { + mu.Lock() + delivered = append(delivered, msg) + mu.Unlock() + }) + + compressed := zlibCompress(t, kookEventBody("zlib hi", "")) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, bytes.NewReader(compressed)) + req.Header.Set("Content-Type", "application/json") + a.InboundHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + mu.Lock() + defer mu.Unlock() + if len(delivered) != 1 || delivered[0].Content != "zlib hi" { + t.Fatalf("delivered = %+v, want one 'zlib hi'", delivered) + } +} + +func TestWebhookHandlerEncrypted(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + delivered []adapter.UnifiedMessage + ) + key := "test-encrypt-key" + a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook, WebhookEncryptKey: key}, + func(_ context.Context, msg adapter.UnifiedMessage) { + mu.Lock() + delivered = append(delivered, msg) + mu.Unlock() + }) + + wrapped, err := json.Marshal(map[string]any{"encrypt": encryptKOOK(t, kookEventBody("secret hi", ""), key)}) + if err != nil { + t.Fatalf("marshal encrypt wrapper: %v", err) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, defaultWebhookPath, bytes.NewReader(wrapped)) + req.Header.Set("Content-Type", "application/json") + a.InboundHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + mu.Lock() + defer mu.Unlock() + if len(delivered) != 1 || delivered[0].Content != "secret hi" { + t.Fatalf("delivered = %+v, want one 'secret hi'", delivered) + } +} + +func TestWebhookHandlerMethodNotAllowed(t *testing.T) { + t.Parallel() + + a := webhookAdapter(t, Config{BotToken: "t", ReceiveMode: modeWebhook}, nil) + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, defaultWebhookPath, nil) + a.InboundHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", rec.Code) + } +} + +func TestConnectWebhookModeSkipsGateway(t *testing.T) { + t.Parallel() + + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + kookFail(w, 1, "should not be called") + })) + defer srv.Close() + + a := New( + Config{BotToken: "t", ReceiveMode: modeWebhook}, + WithBaseURL(srv.URL), + WithHTTPClient(srv.Client()), + WithLogger(discardLogger()), + ) + if err := a.Connect(context.Background()); err != nil { + t.Fatalf("Connect (webhook) = %v", err) + } + if !a.IsConnected() { + t.Fatal("webhook-mode Connect should mark connected") + } + if calls != 0 { + t.Fatalf("gateway was dialed %d time(s) in webhook mode, want 0", calls) + } +} + +func TestDecryptKOOK(t *testing.T) { + t.Parallel() + + key := "test-encrypt-key" + plain := []byte(`{"hello":"world"}`) + enc := encryptKOOK(t, plain, key) + + got, err := decryptKOOK(enc, key) + if err != nil { + t.Fatalf("decryptKOOK: %v", err) + } + if !bytes.Equal(got, plain) { + t.Errorf("decryptKOOK = %q, want %q", got, plain) + } +} + +// zlibCompress zlib-compresses data (KOOK's default callback framing). +func zlibCompress(t *testing.T, data []byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := zlib.NewWriter(&buf) + if _, err := zw.Write(data); err != nil { + t.Fatalf("zlib write: %v", err) + } + if err := zw.Close(); err != nil { + t.Fatalf("zlib close: %v", err) + } + return buf.Bytes() +} + +// encryptKOOK produces the KOOK {"encrypt": ...} value: base64(iv || base64(ciphertext)) +// with AES-256-CBC, key right-padded with NUL to 32 bytes, PKCS7 padding. +func encryptKOOK(t *testing.T, plain []byte, key string) string { + t.Helper() + iv := []byte("0123456789abcdef") + k := padKey(key, 32) + block, err := aes.NewCipher(k) + if err != nil { + t.Fatalf("aes new cipher: %v", err) + } + padded := pkcs7Pad(plain, aes.BlockSize) + ct := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ct, padded) + inner := base64.StdEncoding.EncodeToString(ct) + return base64.StdEncoding.EncodeToString(append(append([]byte{}, iv...), []byte(inner)...)) +} + +// pkcs7Pad pads in to a multiple of size with PKCS7 padding. +func pkcs7Pad(in []byte, size int) []byte { + padding := size - len(in)%size + out := make([]byte, len(in)+padding) + copy(out, in) + for i := len(in); i < len(out); i++ { + out[i] = byte(padding) + } + return out +} + +// TestWebhookReadMessagesBlocks exercises that ReadMessages still blocks until +// ctx cancellation in webhook mode (delivery is push via SetMessageHandler). +func TestWebhookReadMessagesBlocksUntilCancel(t *testing.T) { + a := New(Config{BotToken: "t", ReceiveMode: modeWebhook}, WithLogger(discardLogger())) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + done := make(chan struct{}) + go func() { + a.ReadMessages(ctx, func(adapter.UnifiedMessage) {}) + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("ReadMessages() did not return on canceled context") + } +} diff --git a/plugins/channels/webhook/adapter.go b/plugins/channels/webhook/adapter.go index 1f0ecba..6e3ece7 100644 --- a/plugins/channels/webhook/adapter.go +++ b/plugins/channels/webhook/adapter.go @@ -141,6 +141,11 @@ func (a *Adapter) normalize() { } } +// WebhookPath returns the route the gateway mounts the inbound handler at. It is +// the normalized EndpointPath (default "/webhook"), satisfying the channel +// bridge's webhookAdapter contract. +func (a *Adapter) WebhookPath() string { return a.cfg.EndpointPath } + // Name returns the platform identifier "webhook". func (a *Adapter) Name() string { return PlatformName }