Files
Cyrene_v2/plugins/channels/kook/webhook_test.go
T
Aska 65091f6497
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: KOOK自动获取bot身份 + action/narration剥出气泡 + 渠道配置表单回填
agent-116 KOOK token即用+自动获取:
- kook/identity.go: Connect 调 GET /user/me 拿 bot 身份(bot_id/bot_username/bot_nickname), 失败容错
- 渠道列表 enrichAccountsWithBot 附 bot 身份; base_url空用默认/receive_mode默认ws(只填token可跑)

agent-117 action/narration 剥出气泡(主人需求: 动作/旁白不进气泡,按位置上/下):
- parseContent.splitBubble(content)->{body,head,tail}: body=纯文本(去标签), head=开头/中间action/narr(气泡上), tail=结尾(气泡下)
- renderContent 只渲染text(去内联灰斜体); chat.tsx BubbleNotes 泡外 *动作/旁白* 灰斜体小字(气泡外依附), SplitChatBubble 先剥splitBubble再splitParagraphs(body), <p>/\n\n/毛玻璃/SourceLabel不破坏
- 注: 非chat mode renderContent 现在丢弃action/narr(可接受)

Channels 配置表单回填(修save后输入框空):
- openConfig 从 data.accounts[0].config 回填 token(掩码提示)/base_url/平台字段/黑白名单/限流, 不再硬清空
全仓 build/vet/test绿+前端build绿
2026-09-07 15:31:14 +08:00

382 lines
11 KiB
Go

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 gatewayCalls int
var meCalls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case gatewayPath:
// Webhook and websocket are mutually exclusive: the gateway must never be
// dialed in webhook mode.
gatewayCalls++
kookFail(w, 1, "should not be called")
case userMePath:
// The bot-identity lookup still runs so the adapter resolves who it is.
meCalls++
kookOK(w, map[string]any{"id": "bot-1", "username": "bot", "nickname": "Bot"})
default:
http.NotFound(w, r)
}
}))
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 gatewayCalls != 0 {
t.Fatalf("gateway was dialed %d time(s) in webhook mode, want 0", gatewayCalls)
}
if meCalls != 1 {
t.Fatalf("user/me was called %d time(s), want 1", meCalls)
}
if id, _, _ := a.BotIdentity(); id != "bot-1" {
t.Errorf("BotIdentity id = %q, want bot-1", id)
}
}
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")
}
}