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绿
This commit is contained in:
1 parent
bdd9d752e1
commit
65091f6497
15 files changed
+571
-83
No files matched your search
+2
-1
@@ -39,7 +39,7 @@
|
|||||||
| 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) |
|
| 3 | `/api/v1/admin/memories` | GET | ✅ | 记忆列表(手动添加/检索已实现;编辑/删除/衰减随 M10 扩展) |
|
||||||
| 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) |
|
| 4 | `/api/v1/admin/plugins` | GET | ✅ | 插件/能力列表(含 MCP/渠道) |
|
||||||
| 5 | `/api/v1/admin/plugins/policy` | POST | ✅ | 写工具策略(owner-only,审计) |
|
| 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 设备列表 |
|
| 7 | `/api/v1/admin/iot` | GET | ✅ | IoT 设备列表 |
|
||||||
| 8 | `/api/v1/admin/users` | GET/POST | ✅(GET) | 用户/角色/API key(GET 已实现;POST 创建用户/签发 key 随 M10 落地) |
|
| 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,无需重启) |
|
| 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/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/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` 白名单,绝不靠提示词。
|
- `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`)为兜底。
|
- `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`。
|
- `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`。
|
- `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`。
|
||||||
|
|||||||
@@ -34,12 +34,13 @@ interface PlatformField {
|
|||||||
* are rendered for every platform separately; this map holds only what a given
|
* are rendered for every platform separately; this map holds only what a given
|
||||||
* adapter additionally needs. Keys are the adapter Config json tags verbatim. */
|
* adapter additionally needs. Keys are the adapter Config json tags verbatim. */
|
||||||
const PLATFORM_FIELDS: Record<string, PlatformField[]> = {
|
const PLATFORM_FIELDS: Record<string, PlatformField[]> = {
|
||||||
kook: [
|
// KOOK is "token 即用、其余自动获取": only the token is required. base_url
|
||||||
{ key: "receive_mode", label: "receive_mode(收包模式 ws/webhook)", placeholder: "ws" },
|
// defaults to the official root, receive_mode defaults to "ws" and webhook_path
|
||||||
{ key: "webhook_path", label: "webhook_path(webhook 挂载路由)", placeholder: "/webhook/kook" },
|
// to "/webhook/kook", so none of them need a form field. Advanced values
|
||||||
{ key: "webhook_verify_token", label: "webhook_verify_token(校验 token)", secret: true },
|
// (receive_mode="webhook", webhook_path, webhook_verify_token, webhook_encrypt_key)
|
||||||
{ key: "webhook_encrypt_key", label: "webhook_encrypt_key(AES 加密密钥)", secret: true },
|
// 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": [
|
"qq-obv11": [
|
||||||
{ key: "mode", label: "mode(client/server)", placeholder: "server" },
|
{ key: "mode", label: "mode(client/server)", placeholder: "server" },
|
||||||
{ key: "listen_addr", label: "listen_addr(server 监听)", placeholder: "0.0.0.0:6700" },
|
{ 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) => {
|
const openConfig = (name: string) => {
|
||||||
setErr(null);
|
setErr(null);
|
||||||
setNote(null);
|
setNote(null);
|
||||||
setCfgPlatform(platformOf(name));
|
const platform = platformOf(name);
|
||||||
setCfgId("");
|
setCfgPlatform(platform);
|
||||||
setToken("");
|
// Prefill from the backend-provided account (if already configured) so a
|
||||||
setBaseURL("");
|
// saved config is visible again instead of the form opening blank. token is
|
||||||
setPlatVals({});
|
// masked by the backend (never raw), so we only echo it as a hint.
|
||||||
setAllowGroups("");
|
const acct = (data ?? []).find((c) => platformOf(c.name) === platform)?.accounts?.[0];
|
||||||
setDenyGroups("");
|
setCfgId(acct?.id ?? "");
|
||||||
setAllowUsers("");
|
const cfg = acct?.config ?? {};
|
||||||
setDenyUsers("");
|
setToken(typeof cfg.bot_token === "string" && cfg.bot_token ? "(已配置,仅掩码显示)" : "");
|
||||||
setGroupMsgPerMin("");
|
setBaseURL(typeof cfg.base_url === "string" ? cfg.base_url : "");
|
||||||
setMsgPerMin("");
|
const plat: Record<string, string> = {};
|
||||||
setMaxMessages("");
|
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("");
|
setExtraConfig("");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -253,16 +265,21 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
render: (c) =>
|
render: (c) =>
|
||||||
c.accounts && c.accounts.length ? (
|
c.accounts && c.accounts.length ? (
|
||||||
<span className="flex flex-wrap items-center gap-1">
|
<span className="flex flex-wrap items-center gap-1">
|
||||||
{c.accounts.map((a) => (
|
{c.accounts.map((a) => {
|
||||||
<span key={a.id || "default"} className="flex items-center gap-1">
|
// Show the connected bot's resolved identity (kook user/me) when the
|
||||||
<Tag tone="neutral">{a.id || "default"}</Tag>
|
// adapter fetched it, falling back to the account id.
|
||||||
{a.id ? (
|
const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default");
|
||||||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
return (
|
||||||
删除
|
<span key={a.id || "default"} className="flex items-center gap-1">
|
||||||
</Button>
|
<Tag tone="neutral">{label}</Tag>
|
||||||
) : null}
|
{a.id ? (
|
||||||
</span>
|
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
||||||
))}
|
删除
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
|
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
|
||||||
@@ -316,6 +333,14 @@ export function ChannelsModule(): React.ReactNode {
|
|||||||
{cfgPlatform ? (
|
{cfgPlatform ? (
|
||||||
<Surface className="space-y-3">
|
<Surface className="space-y-3">
|
||||||
<h3 className="text-sm font-semibold">配置账号 → {cfgPlatform}</h3>
|
<h3 className="text-sm font-semibold">配置账号 → {cfgPlatform}</h3>
|
||||||
|
{cfgPlatform === "kook" ? (
|
||||||
|
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||||
|
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 身份。
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<p className="text-xs text-[var(--mu-text-lo)]">
|
<p className="text-xs text-[var(--mu-text-lo)]">
|
||||||
保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
|
保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Icon, type IconName } from "./Icon";
|
|||||||
import { WakePath } from "./status";
|
import { WakePath } from "./status";
|
||||||
import { modeBody } from "./messages";
|
import { modeBody } from "./messages";
|
||||||
import { renderContent } from "../lib/renderContent";
|
import { renderContent } from "../lib/renderContent";
|
||||||
|
import { splitBubble, type ContentPart } from "../lib/parseContent";
|
||||||
|
|
||||||
/** Collapsible inline chain-of-thought block. A Miuix disclosure row: icon chip
|
/** 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
|
* + label, spinner when still thinking, and a single-line muted preview of the
|
||||||
@@ -162,22 +163,24 @@ export function MessageBubble({
|
|||||||
return <AssistantBubble message={message} mode={mode} active={active} />;
|
return <AssistantBubble message={message} mode={mode} active={active} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Split a chat reply into its `<p>`-delimited paragraph bodies. Each complete
|
/** Split a chat reply's annotation-stripped body into `<p>`-delimited paragraph
|
||||||
* `<p>…</p>` block becomes one bubble segment; a reply that carries no `<p>`
|
* bodies. Each complete `<p>…</p>` block becomes one bubble segment; a body that
|
||||||
* wrapper yields an empty list so the caller renders one bubble as before.
|
* carries no `<p>` 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,
|
* Non-greedy + multi-line so a tag may span newlines; a still-streaming,
|
||||||
* unclosed `<p>` simply does not match and stays out until it closes. */
|
* unclosed `<p>` simply does not match and stays out until it closes. */
|
||||||
function splitParagraphs(content: string): string[] {
|
function splitParagraphs(content: string): string[] {
|
||||||
// Preferred: the agent wraps each paragraph in <p>…</p> (design/07 §2). When it
|
// Preferred: the agent wraps each paragraph in <p>…</p> (design/07 §2). When it
|
||||||
// emits plain \n\n runs instead (the model often does), split on those too so
|
// 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
|
// each logical paragraph becomes its own bubble. `content` here is the
|
||||||
// <action>…</action> / <narration>…</narration> block, and never split on a
|
// annotation-stripped body from `splitBubble`, so no <action>/<narration> block
|
||||||
// single \n (that is an in-line break, kept inside the paragraph).
|
// can be cut across; never split on a single \n (that is an in-line break, kept
|
||||||
|
// inside the paragraph).
|
||||||
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||||||
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
||||||
if (pSegments.length > 0) return pSegments;
|
if (pSegments.length > 0) return pSegments;
|
||||||
|
|
||||||
// Keep non-<p> content (action/narration + body) so we don't strip the tags.
|
// Keep non-<p> remainder as its own paragraph bodies.
|
||||||
const segments: string[] = [];
|
const segments: string[] = [];
|
||||||
for (const part of content.split(/\n\s*\n/)) {
|
for (const part of content.split(/\n\s*\n/)) {
|
||||||
if (!part.trim()) continue;
|
if (!part.trim()) continue;
|
||||||
@@ -186,8 +189,37 @@ function splitParagraphs(content: string): string[] {
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span,
|
/** Small muted attachment note for a single `<action>` / `<narration>` block.
|
||||||
* with `<action>`/`<narration>` rendered as muted italic via renderContent. */
|
* 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 (
|
||||||
|
<div className={container}>
|
||||||
|
{items.map((item, i) => (
|
||||||
|
<div key={i} className="text-[10px] italic leading-tight text-[var(--mu-text-lo)]">
|
||||||
|
*{item.type === "action" ? "动作" : "旁白"}:{item.text}*
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)] shadow-[var(--mu-shadow-1)]">
|
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)] shadow-[var(--mu-shadow-1)]">
|
||||||
@@ -267,13 +299,14 @@ function UserParaBubble({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Chat-only entry: a `chat`-mode reply's `<p>…</p>` paragraphs are split into
|
/** Chat-only entry: a `chat`-mode reply's `<action>`/`<narration>` blocks are
|
||||||
* stacked bubbles (first = full bubble, remainder = compact). Any other mode —
|
* peeled off by `splitBubble` into head/tail attachment notes, and the remaining
|
||||||
* code / home / work / docs / vision / voice / agents / settings — is passed
|
* plain-text body is split into stacked bubbles (first = full bubble, remainder
|
||||||
* straight through to MessageBubble so its body renders untouched: no `<p>`
|
* = compact). Any other mode — code / home / work / docs / vision / voice /
|
||||||
* splitting, and its content (which may itself contain `<p>` / `<action>` /
|
* agents / settings — is passed straight through to MessageBubble so its body
|
||||||
* `<narration>` as literal text or code) is never re-parsed. A `chat` message
|
* renders untouched: no `<p>` splitting, and its content (which may itself
|
||||||
* with no `<p>` wrapper renders as one bubble exactly as before. */
|
* contain `<p>` / `<action>` / `<narration>` as literal text or code) is never
|
||||||
|
* re-parsed. */
|
||||||
export function SplitChatBubble({
|
export function SplitChatBubble({
|
||||||
message,
|
message,
|
||||||
active = false,
|
active = false,
|
||||||
@@ -284,20 +317,23 @@ export function SplitChatBubble({
|
|||||||
const mode = message.mode ?? "chat";
|
const mode = message.mode ?? "chat";
|
||||||
if (mode !== "chat") return <MessageBubble message={message} active={active} />;
|
if (mode !== "chat") return <MessageBubble message={message} active={active} />;
|
||||||
|
|
||||||
const segments = splitParagraphs(message.content ?? "");
|
const { body, head, tail } = splitBubble(message.content ?? "");
|
||||||
if (segments.length === 0) {
|
const segments = splitParagraphs(body);
|
||||||
// No complete `<p>…</p>` pair: render the single bubble as-is (but strip a
|
|
||||||
// lone wrapper tag so a still-streaming reply never flashes raw `<p>`).
|
if (segments.length === 0 && head.length === 0 && tail.length === 0) {
|
||||||
const raw = message.content ?? "";
|
// No visible content: hand off so the empty / still-streaming reply keeps its
|
||||||
const clean = raw.replace(/<p[^>]*>/g, "").replace(/<\/p>/g, "");
|
// usual chrome (avatar, mood, "生成中…").
|
||||||
const bubble = clean === raw ? message : { ...message, content: clean };
|
return <MessageBubble message={message} active={active} />;
|
||||||
return <MessageBubble message={bubble} active={active} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group the paragraphs of one reply into a tighter column so intra-reply
|
// 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.
|
// 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 (
|
return (
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className={`flex flex-col gap-2.5 ${message.role === "user" ? "items-end" : ""}`}>
|
||||||
|
<BubbleNotes items={head} align={align} />
|
||||||
{segments.map((seg, i) =>
|
{segments.map((seg, i) =>
|
||||||
message.role === "user" ? (
|
message.role === "user" ? (
|
||||||
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
|
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
|
||||||
@@ -311,6 +347,7 @@ export function SplitChatBubble({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
|
<BubbleNotes items={tail} align={align} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { renderContent } from "../../lib/renderContent";
|
|||||||
|
|
||||||
function shell(children: ReactNode): ReactNode {
|
function shell(children: ReactNode): ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
|
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -116,7 +116,7 @@ export function VisionMessageBody({ message }: { message: ChatMessage }): ReactN
|
|||||||
const hasImage = Boolean(message.image);
|
const hasImage = Boolean(message.image);
|
||||||
if (!message.content && !message.streaming && !hasImage) return null;
|
if (!message.content && !message.streaming && !hasImage) return null;
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]">
|
<div className="liquid-glass-soft overflow-hidden rounded-[var(--mu-radius-lg)]">
|
||||||
{hasImage ? (
|
{hasImage ? (
|
||||||
<img
|
<img
|
||||||
src={message.image as string}
|
src={message.image as string}
|
||||||
@@ -154,7 +154,7 @@ export function VoiceMessageBody({ message }: { message: ChatMessage }): ReactNo
|
|||||||
}, [message.streaming]);
|
}, [message.streaming]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-3">
|
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--mu-accent)]/15 text-[var(--mu-accent)]">
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--mu-accent)]/15 text-[var(--mu-accent)]">
|
||||||
<Icon name="mic" size={20} />
|
<Icon name="mic" size={20} />
|
||||||
@@ -229,6 +229,6 @@ export function modeBody(mode: Mode, message: ChatMessage): ReactNode {
|
|||||||
case "voice": return <VoiceMessageBody message={message} />;
|
case "voice": return <VoiceMessageBody message={message} />;
|
||||||
case "agents": return <AgentsMessageBody message={message} />;
|
case "agents": return <AgentsMessageBody message={message} />;
|
||||||
case "settings": return <SettingsMessageBody message={message} />;
|
case "settings": return <SettingsMessageBody message={message} />;
|
||||||
default: return <div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">{fallbackText(message)}</div>;
|
default: return <div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">{fallbackText(message)}</div>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
// Structured parser for persona action/narration XML tags (design/07 §1).
|
// Structured parser for persona action/narration XML tags (design/07 §1).
|
||||||
// A single message `content` may mix plain text with `<action>…</action>` and
|
// A single message `content` may mix plain text with `<action>…</action>` and
|
||||||
// `<narration>…</narration>`; this splits it into an ordered parts array so the
|
// `<narration>…</narration>`; this splits it into an ordered parts array so the
|
||||||
// UI can render inline (grey italic) while the structured data stays available
|
// UI can position each block (inline, or as an attachment note via `splitBubble`)
|
||||||
// for downstream feeds (mind-map / state) via `summarizeContent`.
|
// while the structured data stays available for downstream feeds (mind-map /
|
||||||
|
// state) via `summarizeContent`.
|
||||||
|
|
||||||
export type ContentPartType = "text" | "action" | "narration";
|
export type ContentPartType = "text" | "action" | "narration";
|
||||||
|
|
||||||
@@ -64,3 +65,58 @@ export function summarizeContent(content: string): ContentSummary {
|
|||||||
}
|
}
|
||||||
return summary;
|
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 `<action>` / `<narration>` 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 };
|
||||||
|
}
|
||||||
@@ -1,19 +1,11 @@
|
|||||||
import type { ReactNode } from "react";
|
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
|
/** Render a message body into its plain-text segments only. `<action>` /
|
||||||
* `<action>` / `<narration>` become muted italic spans (Miuix `--mu-text-lo`).
|
* `<narration>` blocks are NOT rendered inline here — they are peeled out by
|
||||||
* Whitespace is preserved by the caller's `whitespace-pre-wrap` container — the
|
* `splitBubble` and shown as attachment notes outside the bubble. */
|
||||||
* returned nodes are just the segments dropped into it in order. */
|
|
||||||
export function renderContent(content: string): ReactNode[] {
|
export function renderContent(content: string): ReactNode[] {
|
||||||
return parseContent(content).map((part, i) => renderPart(part, i));
|
return parseContent(content)
|
||||||
}
|
.filter((part) => part.type === "text")
|
||||||
|
.map((part, i) => <span key={i}>{part.text}</span>);
|
||||||
function renderPart(part: ContentPart, key: number): ReactNode {
|
|
||||||
if (part.type === "text") return <span key={key}>{part.text}</span>;
|
|
||||||
return (
|
|
||||||
<span key={key} className="italic text-[var(--mu-text-lo)]">
|
|
||||||
{part.text}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -269,6 +269,10 @@ export interface AdminChannelAccount {
|
|||||||
groupMsgPerMin?: number;
|
groupMsgPerMin?: number;
|
||||||
msgPerMin?: number;
|
msgPerMin?: number;
|
||||||
maxMessages?: 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. */
|
/** An IoT device as returned by GET /api/v1/admin/iot. */
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ func (s *Server) adminListPlugins(w http.ResponseWriter, r *http.Request) {
|
|||||||
// accounts, masked so a raw token never reaches the client.
|
// accounts, masked so a raw token never reaches the client.
|
||||||
func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) adminListChannels(w http.ResponseWriter, r *http.Request) {
|
||||||
accountsByPlatform := s.maskedChannelAccounts()
|
accountsByPlatform := s.maskedChannelAccounts()
|
||||||
|
botByPlatform := s.liveBotIdentity()
|
||||||
out := make([]ChannelSummary, 0)
|
out := make([]ChannelSummary, 0)
|
||||||
if s.caps != nil {
|
if s.caps != nil {
|
||||||
for _, cap := range s.caps.List(capreg.KindChannel) {
|
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()}
|
sum := ChannelSummary{Name: ch.Name(), Connected: ch.IsConnected()}
|
||||||
if accts, ok := accountsByPlatform[channelBasePlatform(ch.Name())]; ok && len(accts) > 0 {
|
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)
|
out = append(out, sum)
|
||||||
}
|
}
|
||||||
@@ -200,6 +201,88 @@ func (s *Server) hasChannelSummary(out []ChannelSummary, platform string) bool {
|
|||||||
return false
|
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).
|
// adminListIoT lists IoT devices (admin read).
|
||||||
func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
|
||||||
out := make([]iotDeviceSummary, 0)
|
out := make([]iotDeviceSummary, 0)
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ type AdminChannelAccount struct {
|
|||||||
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
|
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
|
||||||
MsgPerMin int `json:"msgPerMin,omitempty"`
|
MsgPerMin int `json:"msgPerMin,omitempty"`
|
||||||
MaxMessages int `json:"maxMessages,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.
|
// channelConfigRequest is the PUT /api/v1/admin/channels/{platform}/config body.
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
@@ -248,6 +249,21 @@ func (stubChannel) HealthCheck(ctx context.Context) error { return nil }
|
|||||||
|
|
||||||
var _ sdk.Channel = stubChannel{}
|
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.
|
// captureRunner records the sessionID each Run call sees.
|
||||||
type captureRunner struct {
|
type captureRunner struct {
|
||||||
got []string
|
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 --------------------------------------------------
|
// --- Admin: policy errors --------------------------------------------------
|
||||||
|
|
||||||
func TestAdminSetPolicyUnavailable(t *testing.T) {
|
func TestAdminSetPolicyUnavailable(t *testing.T) {
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ type Adapter struct {
|
|||||||
|
|
||||||
chatsMu sync.RWMutex
|
chatsMu sync.RWMutex
|
||||||
chats map[string]adapter.ChannelInfo
|
chats map[string]adapter.ChannelInfo
|
||||||
|
|
||||||
|
botMu sync.RWMutex
|
||||||
|
botID string
|
||||||
|
botUsername string
|
||||||
|
botNickname string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile-time assertion that Adapter satisfies the shared contract.
|
// 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
|
// this map must treat them as secrets (the admin read path masks these keys via
|
||||||
// the "token"/"key" substring rules).
|
// the "token"/"key" substring rules).
|
||||||
func (a *Adapter) Config() map[string]any {
|
func (a *Adapter) Config() map[string]any {
|
||||||
|
id, username, nickname := a.botIdentitySnapshot()
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"bot_token": a.cfg.BotToken,
|
"bot_token": a.cfg.BotToken,
|
||||||
"base_url": a.baseURL,
|
"base_url": a.baseURL,
|
||||||
@@ -124,6 +130,9 @@ func (a *Adapter) Config() map[string]any {
|
|||||||
"webhook_path": a.cfg.WebhookPath,
|
"webhook_path": a.cfg.WebhookPath,
|
||||||
"webhook_verify_token": a.cfg.WebhookVerifyToken,
|
"webhook_verify_token": a.cfg.WebhookVerifyToken,
|
||||||
"webhook_encrypt_key": a.cfg.WebhookEncryptKey,
|
"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
|
// Webhook mode: the Cyrene gateway mounts InboundHandler to receive KOOK
|
||||||
// callbacks (KOOK posts to the configured route). No gateway WebSocket is
|
// callbacks (KOOK posts to the configured route). No gateway WebSocket is
|
||||||
// dialed — webhook and websocket are mutually exclusive. Mark connected so the
|
// dialed — webhook and websocket are mutually exclusive. The bot identity
|
||||||
// REST reply path and HealthCheck see a live session.
|
// 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 {
|
if mode == modeWebhook {
|
||||||
|
a.refreshBotIdentity(ctx)
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.connected = true
|
a.connected = true
|
||||||
a.mu.Unlock()
|
a.mu.Unlock()
|
||||||
@@ -163,6 +175,10 @@ func (a *Adapter) Connect(ctx context.Context) error {
|
|||||||
return fmt.Errorf("kook: connect: %w", err)
|
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())
|
lifeCtx, cancel := context.WithCancel(context.Background())
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.cancel = cancel
|
a.cancel = cancel
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -262,10 +262,22 @@ func TestWebhookHandlerMethodNotAllowed(t *testing.T) {
|
|||||||
func TestConnectWebhookModeSkipsGateway(t *testing.T) {
|
func TestConnectWebhookModeSkipsGateway(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
var calls int
|
var gatewayCalls int
|
||||||
|
var meCalls int
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
calls++
|
switch r.URL.Path {
|
||||||
kookFail(w, 1, "should not be called")
|
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()
|
defer srv.Close()
|
||||||
|
|
||||||
@@ -281,8 +293,14 @@ func TestConnectWebhookModeSkipsGateway(t *testing.T) {
|
|||||||
if !a.IsConnected() {
|
if !a.IsConnected() {
|
||||||
t.Fatal("webhook-mode Connect should mark connected")
|
t.Fatal("webhook-mode Connect should mark connected")
|
||||||
}
|
}
|
||||||
if calls != 0 {
|
if gatewayCalls != 0 {
|
||||||
t.Fatalf("gateway was dialed %d time(s) in webhook mode, want 0", calls)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,9 @@ func TestConnect_GatewayDeliversEvent(t *testing.T) {
|
|||||||
case gatewayPath:
|
case gatewayPath:
|
||||||
wsURL := "ws://" + strings.TrimPrefix(srv.URL, "http://") + "/ws"
|
wsURL := "ws://" + strings.TrimPrefix(srv.URL, "http://") + "/ws"
|
||||||
kookOK(w, map[string]any{"url": wsURL})
|
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":
|
case "/ws":
|
||||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -217,4 +220,8 @@ func TestConnect_GatewayDeliversEvent(t *testing.T) {
|
|||||||
if sid := a.currentSession(); sid != "sess-123" {
|
if sid := a.currentSession(); sid != "sess-123" {
|
||||||
t.Errorf("currentSession = %q, want sess-123", sid)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user