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绿
382 lines
11 KiB
Go
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")
|
|
}
|
|
}
|