feat: KOOK自动获取bot身份 + action/narration剥出气泡 + 渠道配置表单回填
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

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绿
This commit is contained in:
Aska committed 2026-09-07 15:31:14 +08:00
1 parent bdd9d752e1
commit 65091f6497
15 files changed
+571 -83

No files matched your search

+18 -2
View File
@@ -75,6 +75,11 @@ type Adapter struct {
chatsMu sync.RWMutex
chats map[string]adapter.ChannelInfo
botMu sync.RWMutex
botID string
botUsername string
botNickname string
}
// Compile-time assertion that Adapter satisfies the shared contract.
@@ -117,6 +122,7 @@ func (a *Adapter) Name() string { return platformName }
// 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 {
id, username, nickname := a.botIdentitySnapshot()
return map[string]any{
"bot_token": a.cfg.BotToken,
"base_url": a.baseURL,
@@ -124,6 +130,9 @@ func (a *Adapter) Config() map[string]any {
"webhook_path": a.cfg.WebhookPath,
"webhook_verify_token": a.cfg.WebhookVerifyToken,
"webhook_encrypt_key": a.cfg.WebhookEncryptKey,
"bot_id": id,
"bot_username": username,
"bot_nickname": nickname,
}
}
@@ -149,9 +158,12 @@ func (a *Adapter) Connect(ctx context.Context) error {
// 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.
// dialed — webhook and websocket are mutually exclusive. The bot identity
// lookup is best-effort (a bad token or network hiccup logs a warning and
// leaves the identity empty, never aborting the connect), and Mark connected
// so the REST reply path and HealthCheck see a live session.
if mode == modeWebhook {
a.refreshBotIdentity(ctx)
a.mu.Lock()
a.connected = true
a.mu.Unlock()
@@ -163,6 +175,10 @@ func (a *Adapter) Connect(ctx context.Context) error {
return fmt.Errorf("kook: connect: %w", err)
}
// Best-effort bot identity (user/me) so the admin list shows who the bot is.
// Failure here is non-fatal: the gateway URL above already validated the token.
a.refreshBotIdentity(ctx)
lifeCtx, cancel := context.WithCancel(context.Background())
a.mu.Lock()
a.cancel = cancel
+89
View File
@@ -0,0 +1,89 @@
package kook
// This file adds the "token 即用、其余自动获取" bot-identity lookup. After the
// adapter holds a token, Connect calls GET /api/v3/user/me to resolve the bot
// account (id/username/nickname) so the admin channel list can show who the bot
// is without the user hand-configuring any non-token field. The lookup is
// best-effort: a bad token or a transient network failure logs a warning and
// leaves the bot identity empty, but the adapter still establishes its session
// and routes messages normally. The identity is surfaced through Config() under
// the non-sensitive bot_id/bot_username/bot_nickname keys, so it reaches the
// admin read path without ever persisting or logging the token.
import (
"context"
"encoding/json"
"fmt"
)
// userMePath is the KOOK REST endpoint that returns the bot account for a bearer
// token.
const userMePath = "/user/me"
// botIdentity is the "data" payload of GET /api/v3/user/me. Only the three
// display-relevant fields are captured; the rest of the payload is dropped.
type botIdentity struct {
ID string `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
}
// fetchBotIdentity calls GET /api/v3/user/me with the bearer token and decodes
// the bot account. An empty id is treated as a bad response, so a caller can
// distinguish "no account" from "no identity yet".
func (a *Adapter) fetchBotIdentity(ctx context.Context) (botIdentity, error) {
data, err := a.http.GetJSON(ctx, userMePath, nil)
if err != nil {
return botIdentity{}, fmt.Errorf("kook: fetch bot identity: %w", err)
}
var bi botIdentity
if err := json.Unmarshal(data, &bi); err != nil {
return botIdentity{}, fmt.Errorf("kook: decode bot identity: %w", err)
}
if bi.ID == "" {
return botIdentity{}, fmt.Errorf("kook: bot identity empty: %w", ErrBadResponse)
}
return bi, nil
}
// refreshBotIdentity resolves the bot account (best-effort) and stores it on the
// adapter. A failure is logged and ignored so a bad token or network hiccup never
// aborts the connect; the identity is simply left empty. Only the bot's public
// handle/display name is logged — never the token or the user id.
func (a *Adapter) refreshBotIdentity(ctx context.Context) {
bi, err := a.fetchBotIdentity(ctx)
if err != nil {
a.log.Warn("bot identity lookup failed", "err", err)
return
}
a.setBotIdentity(bi)
if bi.Nickname != "" {
a.log.Info("connected as bot", "nickname", bi.Nickname)
} else if bi.Username != "" {
a.log.Info("connected as bot", "username", bi.Username)
}
}
// setBotIdentity stores the resolved bot account under a dedicated mutex.
func (a *Adapter) setBotIdentity(bi botIdentity) {
a.botMu.Lock()
defer a.botMu.Unlock()
a.botID = bi.ID
a.botUsername = bi.Username
a.botNickname = bi.Nickname
}
// botIdentitySnapshot returns the last resolved bot account (empty until a
// successful Connect + user/me lookup).
func (a *Adapter) botIdentitySnapshot() (id, username, nickname string) {
a.botMu.RLock()
defer a.botMu.RUnlock()
return a.botID, a.botUsername, a.botNickname
}
// BotIdentity returns the resolved bot account (id/username/nickname). The values
// are non-sensitive (the bot's public handle and display name) so the admin panel
// may show them.
func (a *Adapter) BotIdentity() (id, username, nickname string) {
return a.botIdentitySnapshot()
}
+97
View File
@@ -0,0 +1,97 @@
package kook
import (
"context"
"net/http"
"testing"
)
// TestFetchBotIdentity exercises the user/me lookup directly: a valid payload
// decodes into the bot account, and the adapter stores it.
func TestFetchBotIdentity(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != userMePath {
http.NotFound(w, r)
return
}
if got := r.Header.Get("Authorization"); got != "Bearer testtoken" {
t.Errorf("Authorization = %q, want Bearer testtoken", got)
}
kookOK(w, map[string]any{"id": "bot-1", "username": "bot", "nickname": "小K"})
}))
bi, err := a.fetchBotIdentity(context.Background())
if err != nil {
t.Fatalf("fetchBotIdentity: %v", err)
}
if bi.ID != "bot-1" || bi.Username != "bot" || bi.Nickname != "小K" {
t.Fatalf("bot identity = %+v, want bot-1/bot/小K", bi)
}
a.setBotIdentity(bi)
if id, username, nickname := a.BotIdentity(); id != "bot-1" || username != "bot" || nickname != "小K" {
t.Errorf("BotIdentity = %q/%q/%q, want bot-1/bot/小K", id, username, nickname)
}
}
// TestFetchBotIdentity_EmptyID confirms that a user/me payload with a blank id is
// a bad response (a caller can distinguish "no account" from "not yet fetched").
func TestFetchBotIdentity_EmptyID(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
kookOK(w, map[string]any{"username": "bot"})
}))
if _, err := a.fetchBotIdentity(context.Background()); err == nil {
t.Fatal("expected an error for a user/me payload with no id")
}
}
// TestRefreshBotIdentity_FailureTolerant verifies the best-effort behavior: a
// failed user/me lookup (bad token / network) logs and leaves the identity empty,
// but does not abort the connect.
func TestRefreshBotIdentity_FailureTolerant(t *testing.T) {
t.Parallel()
a, _ := newTestAdapter(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
kookFail(w, 401, "invalid token")
}))
// Must not panic; identity stays empty.
a.refreshBotIdentity(context.Background())
if id, username, nickname := a.BotIdentity(); id != "" || username != "" || nickname != "" {
t.Errorf("BotIdentity after failure = %q/%q/%q, want empty", id, username, nickname)
}
}
// TestBotIdentityConfigSnapshot confirms the resolved bot identity is surfaced
// through Config() (non-sensitive keys) so the admin read path can display it.
func TestBotIdentityConfigSnapshot(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "x"})
a.setBotIdentity(botIdentity{ID: "bot-5", Username: "bot5", Nickname: "Five"})
cfg := a.Config()
if cfg["bot_id"] != "bot-5" || cfg["bot_username"] != "bot5" || cfg["bot_nickname"] != "Five" {
t.Errorf("config bot identity = %v/%v/%v, want bot-5/bot5/Five",
cfg["bot_id"], cfg["bot_username"], cfg["bot_nickname"])
}
}
// TestConfigDefaults_NoRequirement asserts the "token 即用" contract: with only a
// token, base_url/empty receive_mode/webhook_path resolve to sensible defaults and
// no additional field is required to connect.
func TestConfigDefaults_NoRequirement(t *testing.T) {
t.Parallel()
a := New(Config{BotToken: "token"})
if err := a.cfg.Validate(); err != nil {
t.Fatalf("Validate with token only = %v, want nil", err)
}
got := a.Config()
if got["base_url"] != defaultBaseURL {
t.Errorf("base_url = %v, want default %s", got["base_url"], defaultBaseURL)
}
if got["receive_mode"] != modeWS {
t.Errorf("receive_mode = %v, want %q", got["receive_mode"], modeWS)
}
// Webhook mode with an empty WebhookPath uses the default route.
wh := New(Config{BotToken: "t", ReceiveMode: modeWebhook})
if p := wh.WebhookPath(); p != defaultWebhookPath {
t.Errorf("WebhookPath = %q, want %s", p, defaultWebhookPath)
}
}
+23 -5
View File
@@ -262,10 +262,22 @@ func TestWebhookHandlerMethodNotAllowed(t *testing.T) {
func TestConnectWebhookModeSkipsGateway(t *testing.T) {
t.Parallel()
var calls int
var gatewayCalls int
var meCalls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
kookFail(w, 1, "should not be called")
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()
@@ -281,8 +293,14 @@ func TestConnectWebhookModeSkipsGateway(t *testing.T) {
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)
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)
}
}
+7
View File
@@ -143,6 +143,9 @@ func TestConnect_GatewayDeliversEvent(t *testing.T) {
case gatewayPath:
wsURL := "ws://" + strings.TrimPrefix(srv.URL, "http://") + "/ws"
kookOK(w, map[string]any{"url": wsURL})
case userMePath:
// Connect resolves the bot identity via user/me (best-effort).
kookOK(w, map[string]any{"id": "bot-1", "username": "bot", "nickname": "Bot"})
case "/ws":
conn, err := wsUpgrader.Upgrade(w, r, nil)
if err != nil {
@@ -217,4 +220,8 @@ func TestConnect_GatewayDeliversEvent(t *testing.T) {
if sid := a.currentSession(); sid != "sess-123" {
t.Errorf("currentSession = %q, want sess-123", sid)
}
// Connect fetched the bot identity via user/me (best-effort).
if id, _, nickname := a.BotIdentity(); id != "bot-1" || nickname != "Bot" {
t.Errorf("BotIdentity = %q/%q, want bot-1/Bot", id, nickname)
}
}