diff --git a/Docs/api-client.md b/Docs/api-client.md index c923140..95b8a7d 100644 --- a/Docs/api-client.md +++ b/Docs/api-client.md @@ -39,7 +39,7 @@ | 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) | | 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) | | 5 | `/api/v1/admin/plugins/policy` | POST | ✅ | 写工具策略(owner-only,审计) | -| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1) | +| 6 | `/api/v1/admin/channels` | GET | ✅ | 渠道列表/健康 + 每平台已配账号(脱敏 token 掩码,design/04 §3.1);已连接渠道的账号附 **bot 身份**(`botId`/`botUsername`/`botNickname`,如 KOOK `GET /api/v3/user/me` 自动获取) | | 7 | `/api/v1/admin/iot` | GET | ✅ | IoT 设备列表 | | 8 | `/api/v1/admin/users` | GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;POST 创建用户/签发 key 随 M10 落地) | | 9 | `/api/v1/admin/llm` | GET/POST | ✅ | LLM 多厂商多模型(models.json):GET 列表(APIKey 脱敏,含 `provider` 协议);POST 保存组→落盘 `models.json`→**热更新**运行中的 LLM stack(design/04 §3.1,无需重启) | @@ -75,6 +75,7 @@ - `POST /admin/llm/test` body `{provider?,baseUrl,model,apiKey?,prompt?}` → `200 {data:{ok,provider,model,reply,tokens}}`(**真实模型调用**);无效/失败回 `400 invalid_provider` / `500 llm_test_failed`。API key 仅本次请求内使用,不落库、不进 wire。 - `POST /admin/channels/open`、`POST /admin/channels/close` body `{name}` → `200 {data:{name,connected}}`(真实 Connect/Disconnect);`404`(未注册渠道)/`500`(凭据缺失等连接失败)。 - `POST /admin/channels/test` body `{platform?,accountId,isGroup}` → `200 {data:{role,owner,isGroup,env,verdict}}`。**结构性判定**:channel 层 actor 元数据 × `configs/users/owner.yaml` 白名单,绝不靠提示词。 +- **KOOK「token 即用、其余自动获取」**(适配器契约,非端点):填 KOOK 渠道时**只必填 `token`**(bot_token)。`base_url` 留空即用官方默认 `https://www.kookapp.cn/api/v3`;`receive_mode` 空默认 `ws`(WS 模式地址由 `GET /api/v3/gateway/index?compress=0` 自动获取,已接入);`webhook_path` 空默认 `/webhook/kook`。高级值(改用 webhook、`webhook_verify_token`、`webhook_encrypt_key`,仅 webhook 模式需要)经 PUT 的 `config` JSON 传入(前端走「额外 config」,如 `{"receive_mode":"webhook","webhook_verify_token":"..."}`)。**Connect 拿到 token 后自动调用 `GET /api/v3/user/me`**(Bearer 鉴权)解析 bot 账号(id/username/nickname),存到 adapter 并随 `GET /admin/channels` 账号的 `botId`/`botUsername`/`botNickname` 显示;user/me 失败仅记日志、bot 身份为空,**不中断**连接/收发。token 只在 header / 写文件,**不打印、不进日志**。 - `PUT /admin/channels/{platform}/config`(owner-only,审计)body `{id?,token?,baseURL?,remoteURL?,listenAddr?,mode?,accessToken?,selfID?,config?,allowGroups?,denyGroups?,allowUsers?,denyUsers?,groupMsgPerMin?,msgPerMin?,maxMessages?}` → `201 {data:{persisted:true,path,platform,accountId}}`。**真实持久化 + 热更新**(design/04 §3.1,无需重启):读当前有效配置(channels.json→env 兜底)→ `UpsertChannel` 该平台账号 → 落盘 `channels.json`(本地、gitignore,0600)→ `bridge.Reload(platform)` 热替换该平台 adapter(进行中消息用旧、新消息用新);token 写入文件但**永不在响应/日志回显**(GET 列表为掩码)。`platform` 必填;空配置回 `400`;未注入渠道配置服务回 `500 channels_unavailable`、写盘失败 `500 channel_persist_failed`、热重载失败 `500 channel_reload_failed`。`.env`(`CHANNELS_JSON` / `CHANNEL_*_TOKEN`)为兜底。 - `DELETE /admin/channels/{platform}/config?id={accountId}`(owner-only,审计)→ `200 {data:{persisted:true,path,platform,accountId}}`。删除该平台指定账号 → 落盘 `channels.json` → `bridge.Reload(platform)` 热更新(该账号 adapter 优雅关停)。`id` 必填;未注入服务回 `500 channels_unavailable`。 - `GET /admin/channels/{platform}/messages?limit=&before=`(owner/admin 读)→ `200 {data:[{id,sessionId,role,content,mode?,mood?,image?,platform?,deviceId?,senderName?,createdAt}]}`。**IM 适配器日志流**(design/16 §4.6 §4):该平台**所有非主人私聊/群聊消息**(隔离会话 `sess-xxx`,非主会话 `web`)以消息流显示,`senderName` 标发送者昵称;未注入 `HistoryStore` 回 `501`。 diff --git a/frontend/web/src/admin/modules/Channels.tsx b/frontend/web/src/admin/modules/Channels.tsx index 522febc..06c7165 100644 --- a/frontend/web/src/admin/modules/Channels.tsx +++ b/frontend/web/src/admin/modules/Channels.tsx @@ -34,12 +34,13 @@ interface PlatformField { * are rendered for every platform separately; this map holds only what a given * adapter additionally needs. Keys are the adapter Config json tags verbatim. */ const PLATFORM_FIELDS: Record = { - kook: [ - { key: "receive_mode", label: "receive_mode(收包模式 ws/webhook)", placeholder: "ws" }, - { key: "webhook_path", label: "webhook_path(webhook 挂载路由)", placeholder: "/webhook/kook" }, - { key: "webhook_verify_token", label: "webhook_verify_token(校验 token)", secret: true }, - { key: "webhook_encrypt_key", label: "webhook_encrypt_key(AES 加密密钥)", secret: true }, - ], + // KOOK is "token 即用、其余自动获取": only the token is required. base_url + // defaults to the official root, receive_mode defaults to "ws" and webhook_path + // to "/webhook/kook", so none of them need a form field. Advanced values + // (receive_mode="webhook", webhook_path, webhook_verify_token, webhook_encrypt_key) + // are set through the "额外 config" JSON instead. We keep the (empty) entry so + // the panel still renders the KOOK-specific hint below. + kook: [], "qq-obv11": [ { key: "mode", label: "mode(client/server)", placeholder: "server" }, { key: "listen_addr", label: "listen_addr(server 监听)", placeholder: "0.0.0.0:6700" }, @@ -132,18 +133,29 @@ export function ChannelsModule(): React.ReactNode { const openConfig = (name: string) => { setErr(null); setNote(null); - setCfgPlatform(platformOf(name)); - setCfgId(""); - setToken(""); - setBaseURL(""); - setPlatVals({}); - setAllowGroups(""); - setDenyGroups(""); - setAllowUsers(""); - setDenyUsers(""); - setGroupMsgPerMin(""); - setMsgPerMin(""); - setMaxMessages(""); + const platform = platformOf(name); + setCfgPlatform(platform); + // Prefill from the backend-provided account (if already configured) so a + // saved config is visible again instead of the form opening blank. token is + // masked by the backend (never raw), so we only echo it as a hint. + const acct = (data ?? []).find((c) => platformOf(c.name) === platform)?.accounts?.[0]; + setCfgId(acct?.id ?? ""); + const cfg = acct?.config ?? {}; + setToken(typeof cfg.bot_token === "string" && cfg.bot_token ? "(已配置,仅掩码显示)" : ""); + setBaseURL(typeof cfg.base_url === "string" ? cfg.base_url : ""); + const plat: Record = {}; + for (const f of PLATFORM_FIELDS[platform] ?? []) { + const v = cfg[f.key]; + plat[f.key] = v == null ? "" : String(v); + } + setPlatVals(plat); + setAllowGroups((acct?.allowGroups ?? []).join(",")); + setDenyGroups((acct?.denyGroups ?? []).join(",")); + setAllowUsers((acct?.allowUsers ?? []).join(",")); + setDenyUsers(acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : ""); + setGroupMsgPerMin(cfg.groupMsgPerMin == null ? "" : String(cfg.groupMsgPerMin)); + setMsgPerMin(cfg.msgPerMin == null ? "" : String(cfg.msgPerMin)); + setMaxMessages(cfg.maxMessages == null ? "" : String(cfg.maxMessages)); setExtraConfig(""); }; @@ -253,16 +265,21 @@ export function ChannelsModule(): React.ReactNode { render: (c) => c.accounts && c.accounts.length ? ( - {c.accounts.map((a) => ( - - {a.id || "default"} - {a.id ? ( - - ) : null} - - ))} + {c.accounts.map((a) => { + // Show the connected bot's resolved identity (kook user/me) when the + // adapter fetched it, falling back to the account id. + const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default"); + return ( + + {label} + {a.id ? ( + + ) : null} + + ); + })} ) : ( — @@ -316,6 +333,14 @@ export function ChannelsModule(): React.ReactNode { {cfgPlatform ? (

配置账号 → {cfgPlatform}

+ {cfgPlatform === "kook" ? ( +

+ KOOK 为「token 即用、其余自动获取」:仅需填 token(必填);base_url 留空即用官方默认;receive_mode 默认 ws、webhook_path 默认 /webhook/kook,均无需填写。若改用 webhook 或配校验/加密,在下方「额外 config」JSON 里设置,如 + {" "} + {"{"}"receive_mode":"webhook","webhook_path":"/webhook/kook","webhook_verify_token":"...","webhook_encrypt_key":"..."{"}"} + (verify_token / encrypt_key 仅在 webhook 模式需要)。连接后渠道列表会显示该 KOOK bot 身份。 +

+ ) : null}

保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。

diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx index aa91786..b9dba6a 100644 --- a/frontend/web/src/components/chat.tsx +++ b/frontend/web/src/components/chat.tsx @@ -7,6 +7,7 @@ import { Icon, type IconName } from "./Icon"; import { WakePath } from "./status"; import { modeBody } from "./messages"; import { renderContent } from "../lib/renderContent"; +import { splitBubble, type ContentPart } from "../lib/parseContent"; /** Collapsible inline chain-of-thought block. A Miuix disclosure row: icon chip * + label, spinner when still thinking, and a single-line muted preview of the @@ -162,22 +163,24 @@ export function MessageBubble({ return ; } -/** Split a chat reply into its `

`-delimited paragraph bodies. Each complete - * `

…

` block becomes one bubble segment; a reply that carries no `

` - * wrapper yields an empty list so the caller renders one bubble as before. +/** Split a chat reply's annotation-stripped body into `

`-delimited paragraph + * bodies. Each complete `

…

` block becomes one bubble segment; a body that + * carries no `

` wrapper falls back to splitting on `\n\n` runs, and a body + * with neither yields an empty list so the caller handles the empty reply. * Non-greedy + multi-line so a tag may span newlines; a still-streaming, * unclosed `

` simply does not match and stays out until it closes. */ function splitParagraphs(content: string): string[] { // Preferred: the agent wraps each paragraph in

…

(design/07 §2). When it // emits plain \n\n runs instead (the model often does), split on those too so - // each logical paragraph becomes its own bubble. But never cut inside an - // … / … block, and never split on a - // single \n (that is an in-line break, kept inside the paragraph). + // each logical paragraph becomes its own bubble. `content` here is the + // annotation-stripped body from `splitBubble`, so no / block + // can be cut across; never split on a single \n (that is an in-line break, kept + // inside the paragraph). const pRe = /]*>([\s\S]*?)<\/p>/g; const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim()); if (pSegments.length > 0) return pSegments; - // Keep non-

content (action/narration + body) so we don't strip the tags. + // Keep non-

remainder as its own paragraph bodies. const segments: string[] = []; for (const part of content.split(/\n\s*\n/)) { if (!part.trim()) continue; @@ -186,8 +189,37 @@ function splitParagraphs(content: string): string[] { return segments; } -/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span, - * with ``/`` rendered as muted italic via renderContent. */ +/** Small muted attachment note for a single `` / `` block. + * Rendered as a grey italic micro-line OUTSIDE the bubble (anchored to the + * message's head/tail by `splitBubble`) — never inside the bubble body. + * `align` mirrors the bubble column: `left` indents under the assistant avatar + * column, `right` lines the note up with a right-aligned user bubble. */ +function BubbleNotes({ + items, + align = "left", +}: { + items: ContentPart[]; + align?: "left" | "right"; +}): React.ReactNode { + if (items.length === 0) return null; + const container = + align === "right" + ? "flex flex-col items-end gap-0.5" + : "flex flex-col items-start gap-0.5 pl-10"; + return ( +

+ {items.map((item, i) => ( +
+ *{item.type === "action" ? "动作" : "旁白"}:{item.text}* +
+ ))} +
+ ); +} + +/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span. + * `text` is already the annotation-stripped body paragraph from `splitBubble`, + * so it renders as plain text only — action/narration notes live outside. */ function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode { return (
@@ -267,13 +299,14 @@ function UserParaBubble({ ); } -/** Chat-only entry: a `chat`-mode reply's `

…

` paragraphs are split into - * stacked bubbles (first = full bubble, remainder = compact). Any other mode — - * code / home / work / docs / vision / voice / agents / settings — is passed - * straight through to MessageBubble so its body renders untouched: no `

` - * splitting, and its content (which may itself contain `

` / `` / - * `` as literal text or code) is never re-parsed. A `chat` message - * with no `

` wrapper renders as one bubble exactly as before. */ +/** Chat-only entry: a `chat`-mode reply's ``/`` blocks are + * peeled off by `splitBubble` into head/tail attachment notes, and the remaining + * plain-text body is split into stacked bubbles (first = full bubble, remainder + * = compact). Any other mode — code / home / work / docs / vision / voice / + * agents / settings — is passed straight through to MessageBubble so its body + * renders untouched: no `

` splitting, and its content (which may itself + * contain `

` / `` / `` as literal text or code) is never + * re-parsed. */ export function SplitChatBubble({ message, active = false, @@ -284,20 +317,23 @@ export function SplitChatBubble({ const mode = message.mode ?? "chat"; if (mode !== "chat") return ; - const segments = splitParagraphs(message.content ?? ""); - if (segments.length === 0) { - // No complete `

…

` pair: render the single bubble as-is (but strip a - // lone wrapper tag so a still-streaming reply never flashes raw `

`). - const raw = message.content ?? ""; - const clean = raw.replace(/]*>/g, "").replace(/<\/p>/g, ""); - const bubble = clean === raw ? message : { ...message, content: clean }; - return ; + const { body, head, tail } = splitBubble(message.content ?? ""); + const segments = splitParagraphs(body); + + if (segments.length === 0 && head.length === 0 && tail.length === 0) { + // No visible content: hand off so the empty / still-streaming reply keeps its + // usual chrome (avatar, mood, "生成中…"). + return ; } // Group the paragraphs of one reply into a tighter column so intra-reply // segments read as one bubble while separate replies keep the 16px list gap. + // The annotation notes (head/tail) attach to the whole group — above the first + // bubble and below the last — never inside a bubble's body. + const align = message.role === "user" ? "right" : "left"; return ( -

+
+ {segments.map((seg, i) => message.role === "user" ? ( @@ -311,6 +347,7 @@ export function SplitChatBubble({ /> ), )} +
); } diff --git a/frontend/web/src/components/messages/index.tsx b/frontend/web/src/components/messages/index.tsx index 33ccf62..efa865a 100644 --- a/frontend/web/src/components/messages/index.tsx +++ b/frontend/web/src/components/messages/index.tsx @@ -15,7 +15,7 @@ import { renderContent } from "../../lib/renderContent"; function shell(children: ReactNode): ReactNode { return ( -
+
{children}
); @@ -116,7 +116,7 @@ export function VisionMessageBody({ message }: { message: ChatMessage }): ReactN const hasImage = Boolean(message.image); if (!message.content && !message.streaming && !hasImage) return null; return ( -
+
{hasImage ? ( +
@@ -229,6 +229,6 @@ export function modeBody(mode: Mode, message: ChatMessage): ReactNode { case "voice": return ; case "agents": return ; case "settings": return ; - default: return
{fallbackText(message)}
; + default: return
{fallbackText(message)}
; } } diff --git a/frontend/web/src/lib/parseContent.ts b/frontend/web/src/lib/parseContent.ts index b74dfcc..87b9e11 100644 --- a/frontend/web/src/lib/parseContent.ts +++ b/frontend/web/src/lib/parseContent.ts @@ -1,8 +1,9 @@ // Structured parser for persona action/narration XML tags (design/07 §1). // A single message `content` may mix plain text with `…` and // `…`; this splits it into an ordered parts array so the -// UI can render inline (grey italic) while the structured data stays available -// for downstream feeds (mind-map / state) via `summarizeContent`. +// UI can position each block (inline, or as an attachment note via `splitBubble`) +// while the structured data stays available for downstream feeds (mind-map / +// state) via `summarizeContent`. export type ContentPartType = "text" | "action" | "narration"; @@ -64,3 +65,58 @@ export function summarizeContent(content: string): ContentSummary { } return summary; } + +/** Result of `splitBubble`: a renderable plain-text `body` plus the annotation + * blocks that belong above (`head`) or below (`tail`) the bubble. `head`/`tail` + * hold the leading / trailing action & narration parts in order; `body` is the + * concatenated text with any annotation blocks removed. */ +export interface BubbleSplit { + body: string; + head: ContentPart[]; + tail: ContentPart[]; +} + +/** + * Peel the `` / `` blocks out of `content` so the bubble + * body is pure text while the annotations attach as notes outside it. + * + * - `head` = the leading run of action/narration before the FIRST text part + * (rendered above the bubble). + * - `tail` = the trailing run of action/narration after the LAST text part + * (rendered below the bubble). + * - `body` = every text part concatenated and outer-trimmed. + * - Annotation blocks interleaved BETWEEN text paragraphs have no stable text + * anchor once the body collapses, so they lift to `head` — the only hard + * requirement is that they never render inside the bubble. + * - A content with no text part at all yields an empty `body` with everything + * in `head`, so the notes alone carry the message. + */ +export function splitBubble(content: string): BubbleSplit { + const parts = parseContent(content); + let firstTextIdx = -1; + let lastTextIdx = -1; + parts.forEach((part, i) => { + if (part.type === "text") { + if (firstTextIdx < 0) firstTextIdx = i; + lastTextIdx = i; + } + }); + + const head: ContentPart[] = []; + const tail: ContentPart[] = []; + let bodyText = ""; + + parts.forEach((part, i) => { + if (part.type === "text") { + bodyText += part.text; + return; + } + if (firstTextIdx < 0 || i < firstTextIdx || (i > firstTextIdx && i < lastTextIdx)) { + head.push(part); + } else { + tail.push(part); + } + }); + + return { body: bodyText.trim(), head, tail }; +} diff --git a/frontend/web/src/lib/renderContent.tsx b/frontend/web/src/lib/renderContent.tsx index b50ce23..1b32965 100644 --- a/frontend/web/src/lib/renderContent.tsx +++ b/frontend/web/src/lib/renderContent.tsx @@ -1,19 +1,11 @@ import type { ReactNode } from "react"; -import { parseContent, type ContentPart } from "../lib/parseContent"; +import { parseContent } from "../lib/parseContent"; -/** Render a message body into inline parts: plain text as-is, while - * `` / `` become muted italic spans (Miuix `--mu-text-lo`). - * Whitespace is preserved by the caller's `whitespace-pre-wrap` container — the - * returned nodes are just the segments dropped into it in order. */ +/** Render a message body into its plain-text segments only. `` / + * `` blocks are NOT rendered inline here — they are peeled out by + * `splitBubble` and shown as attachment notes outside the bubble. */ export function renderContent(content: string): ReactNode[] { - return parseContent(content).map((part, i) => renderPart(part, i)); -} - -function renderPart(part: ContentPart, key: number): ReactNode { - if (part.type === "text") return {part.text}; - return ( - - {part.text} - - ); + return parseContent(content) + .filter((part) => part.type === "text") + .map((part, i) => {part.text}); } diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts index 70c950d..590c796 100644 --- a/frontend/web/src/lib/types.ts +++ b/frontend/web/src/lib/types.ts @@ -269,6 +269,10 @@ export interface AdminChannelAccount { groupMsgPerMin?: number; msgPerMin?: number; maxMessages?: number; + /** Connected bot identity (kook resolves it from /api/v3/user/me). */ + botId?: string; + botUsername?: string; + botNickname?: string; } /** An IoT device as returned by GET /api/v1/admin/iot. */ diff --git a/internal/services/gateway/admin.go b/internal/services/gateway/admin.go index 7e5aeaa..cdea390 100644 --- a/internal/services/gateway/admin.go +++ b/internal/services/gateway/admin.go @@ -142,6 +142,7 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) { // accounts, masked so a raw token never reaches the client. func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { accountsByPlatform := s.maskedChannelAccounts() + botByPlatform := s.liveBotIdentity() out := make([]ChannelSummary, 0) if s.caps != nil { for _, cap := range s.caps.List(capreg.KindChannel) { @@ -151,7 +152,7 @@ func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) { } sum := ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()} if accts, ok := accountsByPlatform[channelBasePlatform(ch.Name())]; ok && len(accts) > 0 { - sum.Accounts = accts + sum.Accounts = enrichAccountsWithBot(accts, botByPlatform[channelBasePlatform(ch.Name())]) } out = append(out, sum) } @@ -200,6 +201,88 @@ func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool { return false } +// botInfo is the live, connected bot's resolved identity (kook fetches it from +// /api/v3/user/me on Connect). It is surfaced through the adapter's Config() so +// the admin list can show who the bot is without persisting it. +type botInfo struct { + id string + username string + nickname string +} + +// empty reports whether no part of the bot identity resolved. +func (b botInfo) empty() bool { return b.id == "" && b.username == "" && b.nickname == "" } + +// liveBotIdentity collects the bot identity each registered channel adapter +// reports, keyed by bare platform then account id (a bare adapter name yields an +// empty account id). Adapters that do not expose bot identity (telegram, +// webhook, ...) contribute nothing, so other platforms are unaffected. +func (s *Server) liveBotIdentity() map[string]map[string]botInfo { + out := make(map[string]map[string]botInfo) + if s.caps == nil { + return out + } + for _, cap := range s.caps.List(capreg.KindChannel) { + ch, ok := cap.Value.(interface{ Config() map[string]any }) + if !ok { + continue + } + info := botIdentityOf(ch.Config()) + if info.empty() { + continue + } + platform, acct := splitAdapterName(cap.Name) + if out[platform] == nil { + out[platform] = make(map[string]botInfo) + } + out[platform][acct] = info + } + return out +} + +// botIdentityOf reads the optional bot-identity fields off an adapter config map. +// The keys are non-sensitive (bot_id / bot_username / bot_nickname) so they are +// never masked by runtimeconfig.Masked. +func botIdentityOf(cfg map[string]any) botInfo { + if cfg == nil { + return botInfo{} + } + var b botInfo + b.id, _ = cfg["bot_id"].(string) + b.username, _ = cfg["bot_username"].(string) + b.nickname, _ = cfg["bot_nickname"].(string) + return b +} + +// splitAdapterName splits a (possibly multi-account) adapter name like +// "kook:conf0" into ("kook", "conf0"); a bare name yields an empty account id. +func splitAdapterName(name string) (platform, acct string) { + if base, tail, ok := strings.Cut(name, ":"); ok { + return base, tail + } + return name, "" +} + +// enrichAccountsWithBot merges the live bot identity into the matching account(s) +// of a platform. An account is matched to its live adapter by id, so a +// multi-account platform shows each bot's own identity. Accounts without a live +// bot (not connected yet) keep their stored fields unchanged. +func enrichAccountsWithBot(accts []AdminChannelAccount, by map[string]botInfo) []AdminChannelAccount { + if len(by) == 0 { + return accts + } + out := make([]AdminChannelAccount, 0, len(accts)) + for _, a := range accts { + if info, ok := by[a.ID]; ok && !info.empty() { + a.BotID = info.id + a.BotUsername = info.username + a.BotNickname = info.nickname + } + out = append(out, a) + } + return out +} + // adminListIoT lists IoT devices (admin read). func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) { out := make([]iotDeviceSummary, 0) diff --git a/internal/services/gateway/admin_write_channel_config.go b/internal/services/gateway/admin_write_channel_config.go index fcb72ea..c125af6 100644 --- a/internal/services/gateway/admin_write_channel_config.go +++ b/internal/services/gateway/admin_write_channel_config.go @@ -35,6 +35,13 @@ type AdminChannelAccount struct { GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"` MsgPerMin int `json:"msgPerMin,omitempty"` MaxMessages int `json:"maxMessages,omitempty"` + // BotID/BotUsername/BotNickname are the connected bot's resolved identity + // (kook fetches it from /api/v3/user/me on connect). They come from the live + // adapter, not the persisted channels.json config, and are non-sensitive (the + // bot's public handle/display name), so the panel may show them. + BotID string `json:"botId,omitempty"` + BotUsername string `json:"botUsername,omitempty"` + BotNickname string `json:"botNickname,omitempty"` } // channelConfigRequest is the PUT /api/v1/admin/channels/{platform}/config body. diff --git a/internal/services/gateway/extra_test.go b/internal/services/gateway/extra_test.go index d12e248..649a9c2 100644 --- a/internal/services/gateway/extra_test.go +++ b/internal/services/gateway/extra_test.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" "testing/fstest" @@ -248,6 +249,21 @@ func (stubChannel) HealthCheck(ctx context.Context) error { return nil } var _ sdk.Channel = stubChannel{} +// stubBotChannel mimics a live kook adapter that resolved its bot identity via +// GET /api/v3/user/me on Connect (surfaced through Config()). +type stubBotChannel struct{} + +func (stubBotChannel) Name() string { return "kook:conf0" } +func (stubBotChannel) Connect(ctx context.Context) error { return nil } +func (stubBotChannel) Disconnect(ctx context.Context) error { return nil } +func (stubBotChannel) IsConnected() bool { return true } +func (stubBotChannel) HealthCheck(ctx context.Context) error { return nil } +func (stubBotChannel) Config() map[string]any { + return map[string]any{"bot_id": "bot-7", "bot_username": "bot7", "bot_nickname": "小K"} +} + +var _ sdk.Channel = stubBotChannel{} + // captureRunner records the sessionID each Run call sees. type captureRunner struct { got []string @@ -553,6 +569,46 @@ func TestAdminChannelsFromRegistry(t *testing.T) { } } +// TestAdminChannels_SurfacesBotIdentity verifies a connected kook adapter's +// resolved bot identity (from user/me) reaches the accounts of GET /admin/channels +// so the panel can show who the bot is. +func TestAdminChannels_SurfacesBotIdentity(t *testing.T) { + t.Parallel() + caps := capreg.New() + if err := caps.RegisterChannel(stubBotChannel{}); err != nil { + t.Fatalf("register channel: %v", err) + } + store := &stubChannelConfigStore{ + path: filepath.Join(t.TempDir(), "channels.json"), + accounts: map[string][]config.ChannelAccount{ + "kook": {{ID: "conf0", Config: map[string]any{"bot_token": "tk"}}}, + }, + } + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Registry: caps, Channels: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/channels", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + var items []struct { + Name string `json:"name"` + Accounts []AdminChannelAccount `json:"accounts"` + } + if err := json.Unmarshal(decodeBody(t, body)["data"], &items); err != nil { + t.Fatalf("decode: %v", err) + } + if len(items) != 1 || items[0].Name != "kook:conf0" { + t.Fatalf("items = %+v, want single kook:conf0", items) + } + if len(items[0].Accounts) != 1 { + t.Fatalf("accounts = %+v, want one", items[0].Accounts) + } + acct := items[0].Accounts[0] + if acct.BotNickname != "小K" || acct.BotUsername != "bot7" || acct.BotID != "bot-7" { + t.Errorf("account bot identity = %+v, want 小K/bot7/bot-7", acct) + } +} + // --- Admin: policy errors -------------------------------------------------- func TestAdminSetPolicyUnavailable(t *testing.T) { diff --git a/plugins/channels/kook/adapter.go b/plugins/channels/kook/adapter.go index 2314f96..805af7c 100644 --- a/plugins/channels/kook/adapter.go +++ b/plugins/channels/kook/adapter.go @@ -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 diff --git a/plugins/channels/kook/identity.go b/plugins/channels/kook/identity.go new file mode 100644 index 0000000..6093caf --- /dev/null +++ b/plugins/channels/kook/identity.go @@ -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() +} diff --git a/plugins/channels/kook/identity_test.go b/plugins/channels/kook/identity_test.go new file mode 100644 index 0000000..e853d45 --- /dev/null +++ b/plugins/channels/kook/identity_test.go @@ -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) + } +} diff --git a/plugins/channels/kook/webhook_test.go b/plugins/channels/kook/webhook_test.go index be65a93..c6c95b2 100644 --- a/plugins/channels/kook/webhook_test.go +++ b/plugins/channels/kook/webhook_test.go @@ -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) } } diff --git a/plugins/channels/kook/websocket_test.go b/plugins/channels/kook/websocket_test.go index 3fe10f3..1c6ac2f 100644 --- a/plugins/channels/kook/websocket_test.go +++ b/plugins/channels/kook/websocket_test.go @@ -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) + } }