cortico-world-qq-better 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ENV_PROMPT.md ADDED
@@ -0,0 +1,27 @@
1
+ # QQ 环境
2
+
3
+ {{qq.self}}
4
+
5
+ ## 你正在监听的群
6
+ {{qq.activeGroups}}
7
+
8
+ ## 你正在监听的私聊
9
+ {{qq.activePrivates}}
10
+
11
+ ## 未读情况
12
+ {{qq.unread}}
13
+
14
+ 当你在这些群里被 @ 或有人私聊你时,会收到消息事件。回复群友时尽量自然、简洁;涉及敏感或重要决定时,先用 qq_send 起草、再用 qq_confirm_send 确认发送,避免误发。
15
+
16
+ ## 回复渠道(重要)
17
+ - 你收到的 QQ 消息事件,其文本以 `【QQ群 …】` 或 `【QQ私聊 …】` 开头,meta.channel 为 `qq`。这类消息**必须回到 QQ 原会话回复**:用 `qq_send` 起草(`to` 直接填事件 meta 里的 `conv` 字段——群聊是 `group:<群号>`、私聊是 `private:<QQ号>`,原样复制即可,不要自己拆数字),再用 `qq_confirm_send` 确认发送。
18
+ - **严禁用 `terminal_send` 回复 QQ 消息**——`terminal_send` 只发往控制台终端(网页聊天框),对方在 QQ 里收不到。
19
+ - 群消息:被 @ 才回复;私聊消息:直接回复。
20
+
21
+ ## 发送语法(qq_send 的正文)
22
+ 起草文本时可混用以下标记,发送时会自动转成对应 OneBot 段:
23
+ - `@名字` 或 `@QQ号`:提及某人。
24
+ - `[表情N]` / `[表情N:名称]`:按数字 id 发送 QQ 表情(如 `[表情14]`)。
25
+ - `[旺柴]`、`[笑哭]`、`[调皮]` 等中文表情名:自动映射到对应表情 id(与 fat-fish 一致)。
26
+ - `[表情包:文件名]`:发送本地表情包图片,文件需放在「表情包目录」配置所指位置(如 `emoji/Dog.png`);文件不存在则该标记被丢弃,不报错。
27
+ - 纯文本按句号/问号/感叹号/分号/换行自动拆成多条消息发送(可在控制台关掉「按句拆分发送」);多条之间按「发送间隔」限速,防止触发风控。
package/README.md ADDED
@@ -0,0 +1,80 @@
1
+ # cortico-world-qq
2
+
3
+ Cortico 的 **QQ(OneBot v11 / NapCat)** World 扩展。以 `fat-fish` 仓库的 `qq_bot` 插件为**本体**,并增量吸收了 Cortico 内置 `qq` world 的特性。
4
+
5
+ - 包名:`cortico-world-qq`
6
+ - World id:`qqbot`(与内置 `qq` 区分,可并存;如需独占可禁用内置 world)
7
+ - 框架契约:`api: 3`
8
+
9
+ ## 与 fat-fish / 内置 qq 的关系
10
+
11
+ | 能力 | fat-fish 插件 | 本扩展 | 说明 |
12
+ |---|---|---|---|
13
+ | 连接方式 | 反向 WS(Cortico 开服务端等 NapCat 连) | ✅ 反向(默认)+ 正向 | 配置 `mode` 切换 |
14
+ | 消息解析 | 文本/图片/语音(SILK)/视频、@判断、表情码、私聊聚合、群白名单、引用回复 | ✅ 全部移植 | fat-fish 的 `QQPlugin`/`QQAdapter` 逻辑 |
15
+ | 文本分句发送 | `split_for_sending` 按句拆分 + 发送限速 | ✅ 按句拆分(含表情包图片/中文表情名)+ 分片 `send_msg` + `sendIntervalMs` 间隔限速 | 单条仍超 `maxMessageBytes` 再按字节硬切 |
16
+ | 草稿-确认门 | — | ✅ 增量吸收 | `qq_send` 起草 + `qq_confirm_send` 确认 |
17
+ | 控制台面板 | — | ✅ 增量吸收 | 连接/监听名单/实时事件流 |
18
+ | 环境提示词 | — | ✅ 增量吸收 | `envPromptVars` + `ENV_PROMPT.md` |
19
+ | 通知事件 | 撤回/退群等 | ✅ 增量吸收 | recall / member / poke / emoji |
20
+ | 被动视觉(VLM) | — | ✅ 增量吸收(默认关) | 可选 OpenAI 兼容端点描述图片 |
21
+ | 历史查询 | — | ✅ 增量吸收 | `qq_recent` / `qq_search` |
22
+
23
+ ## 连接模式
24
+
25
+ - `reverse`(默认,fat-fish 风格):本扩展开一个 WebSocketServer(配置 `wsHost`/`wsPort`/`wsPath`),在 NapCat 里把「反向 WebSocket」指向 `ws://<host>:<port><path>`,NapCat 主动连入。
26
+ - `forward`(内置风格):本扩展作为客户端,配置 `wsUrl` 指向 NapCat 暴露的 OneBot WebSocket 地址。
27
+
28
+ 两种模式走同一套帧处理:带 `echo` 的是 API 回包,带 `post_type` 的是事件。
29
+
30
+ ## 配置(`config.json` 的 `worlds.qqbot` 节)
31
+
32
+ | 字段 | 默认 | 说明 |
33
+ |---|---|---|
34
+ | `enabled` | `false` | 启用本 world |
35
+ | `mode` | `reverse` | `reverse` / `forward` |
36
+ | `wsUrl` | `ws://127.0.0.1:3001` | 正向模式地址 |
37
+ | `wsHost` / `wsPort` / `wsPath` | `0.0.0.0` / `8080` / `/onebot/v11` | 反向模式监听 |
38
+ | `token` | `""` | OneBot access_token(两端一致;空=不校验) |
39
+ | `groups` / `privates` | `[]` | 监听群号 / 私聊 QQ;`groups` 为空=接入全部群 |
40
+ | `privateAggregationWindow` | `30000` | 私聊连续消息聚合窗口(ms) |
41
+ | `maxMessageBytes` | `1500` | 长文本强制分片字节数 |
42
+ | `deliverImageAttachments` | `true` | 图片作为模型附件(需模型支持图像) |
43
+ | `vision.enabled` | `false` | 被动视觉描述图片(需配置 OPENROUTER_API_KEY) |
44
+ | `vision.model` | `google/gemini-2.5-flash` | VLM 模型 |
45
+
46
+ > 连接类参数(mode/地址/端口/token)改动后需**整机重启**生效;监听名单(groups/privates)同理。
47
+
48
+ ## 工具
49
+
50
+ - `qq_send`:起草要发送的消息(群或私聊),返回草稿编号。**不会立即发送**。
51
+ - `qq_confirm_send`:确认并真正发送草稿(带 `barrierAfter`,防止连续误发)。
52
+ - `qq_recent`:查询某群/私聊最近消息。
53
+ - `qq_search`:按关键词检索历史消息。
54
+
55
+ ## 控制台
56
+
57
+ - **面板「监听名单」**:显示当前监听的群/私聊、成员数、最后消息时间。
58
+ - **面板「实时事件」**:订阅并实时回放 QQ 消息与通知流。
59
+ - **配置组「QQ · 连接与监听」**:在控制台编辑连接参数与监听名单(热保存,连接类需重启)。
60
+
61
+ ## 部署
62
+
63
+ 1. 把本目录装进部署副本:`Cortico/extensions/node_modules/cortico-world-qq/`(保留 `src/`、`package.json`)。
64
+ 2. `cd` 到该副本,`corepack pnpm install --prod` 安装 `ws` 依赖。
65
+ 3. 在部署的 `config.json` 写入:
66
+ ```json
67
+ { "worlds": { "qqbot": { "enabled": true, "mode": "reverse", "wsPort": 8080, "groups": [123456789] } } }
68
+ ```
69
+ 4. 整机重启 Cortico(`POST /api/run/restart` 或重启进程)。
70
+ 5. 在 NapCat 配置反向/正向 WebSocket 指向本扩展。
71
+
72
+ ## 已知限制 / 与原版的差异
73
+
74
+ - 语音解码依赖系统 `ffmpeg`(best-effort):缺失时语音仅以 `[语音]` 占位,不阻塞事件。
75
+ - 合并转发展开、视频/文件等大二进制默认只做文本占位(不自动作为附件,避免撑爆上下文)。
76
+ - 群成员名片为空时尝试补一次 `get_group_member_info`,但离线名片仍以 NapCat 推送为准。
77
+ - 引用回复走实时 `get_msg`(与 fat-fish 一致),需要 NapCat 暴露该 API;引用库外消息也能正确拉取。
78
+ - 表情包图片通过 `[表情包:文件名]` 发送,文件需放在 `emojiDir` 配置目录(默认 `<扩展>/emoji`);文件缺失则该标记被丢弃,不报错。
79
+ - 中文表情名(如 `[旺柴]`/`[笑哭]`)按 fat-fish 的 `FACE_MAP` 映射到 face id;未知方括号文本原样保留。
80
+ - 被动视觉默认关闭;开启需自备 OpenAI 兼容(如 OpenRouter)Key 与可访问的图片 URL。
@@ -0,0 +1,99 @@
1
+ // src/console/client.ts
2
+ function fmtClock(ts) {
3
+ const n = typeof ts === "number" ? ts : typeof ts === "string" ? Date.parse(ts) : NaN;
4
+ if (!Number.isFinite(n)) return "";
5
+ const d = new Date(n);
6
+ if (Number.isNaN(d.getTime())) return "";
7
+ const p = (x) => String(x).padStart(2, "0");
8
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
9
+ }
10
+ function fmtTime(ts) {
11
+ const n = typeof ts === "number" ? ts : typeof ts === "string" ? Date.parse(ts) : NaN;
12
+ if (!Number.isFinite(n)) return "\u2014";
13
+ const d = new Date(n);
14
+ if (Number.isNaN(d.getTime())) return "\u2014";
15
+ return `${d.getMonth() + 1}/${d.getDate()} ${fmtClock(n)}`;
16
+ }
17
+ var rosterPanel = {
18
+ async mount(ctx) {
19
+ const ui = ctx.ui;
20
+ const sheet = ui.sheet({ title: "\u76D1\u542C\u540D\u5355", en: "roster", desc: "\u5F53\u524D\u76D1\u542C\u7684\u7FA4\u804A\u4E0E\u79C1\u804A\uFF0C\u53CA\u6700\u8FD1\u6D88\u606F\u65F6\u95F4\u3002" });
21
+ let data;
22
+ try {
23
+ data = await ctx.invoke("getRoster");
24
+ } catch (err) {
25
+ sheet.body.appendChild(ui.msgline(`\u8BFB\u53D6\u76D1\u542C\u540D\u5355\u5931\u8D25: ${err.message}`, true));
26
+ ctx.root.append(sheet.el);
27
+ return;
28
+ }
29
+ data ?? (data = {});
30
+ const groups = data.groups ?? [];
31
+ const privates = data.privates ?? [];
32
+ const convs = data.convs ?? [];
33
+ const bar = ui.rowbar();
34
+ bar.append(
35
+ ui.pill(`\u672C\u53F7 ${data.selfId ?? "?"}`, "plain"),
36
+ ui.pill(`\u6A21\u5F0F ${data.mode ?? "?"}`, "plain"),
37
+ ui.pill(`\u7FA4 ${groups.length}`, "plain"),
38
+ ui.pill(`\u79C1\u804A ${privates.length}`, "plain")
39
+ );
40
+ sheet.body.append(bar);
41
+ if (convs.length === 0) {
42
+ sheet.body.append(ui.placeholder("\u8FD8\u6CA1\u6709\u4EFB\u4F55\u4F1A\u8BDD\u3002\u8BA9 bot \u6536\u5230\u6D88\u606F\u6216\u628A\u5B83\u52A0\u5165\u76D1\u542C\u540D\u5355\u540E\uFF0C\u8FD9\u91CC\u4F1A\u51FA\u73B0\u8BB0\u5F55\u3002"));
43
+ } else {
44
+ const table = ui.table({
45
+ head: ["\u7C7B\u578B", "\u540D\u79F0", "\u8D26\u53F7", "\u4EBA\u6570", "\u5DF2\u6FC0\u6D3B", "\u6700\u8FD1\u6D88\u606F"],
46
+ maxHeight: "calc(100vh - 320px)"
47
+ });
48
+ for (const c of convs) {
49
+ const kindLabel = c.kind === "group" ? "\u7FA4" : c.kind === "private" ? "\u79C1\u804A" : String(c.kind);
50
+ table.addRow([
51
+ kindLabel,
52
+ c.label ?? "",
53
+ String(c.address),
54
+ String(c.members ?? 0),
55
+ c.active ? "\u662F" : "\u5426",
56
+ fmtTime(c.lastMessageAt)
57
+ ]);
58
+ }
59
+ sheet.body.append(table.el);
60
+ }
61
+ ctx.root.append(sheet.el);
62
+ }
63
+ };
64
+ var eventsPanel = {
65
+ mount(ctx) {
66
+ const ui = ctx.ui;
67
+ const sheet = ui.sheet({ title: "\u5B9E\u65F6\u4E8B\u4EF6", en: "events", desc: "QQ \u6D88\u606F\u4E0E\u901A\u77E5\u7684\u5B9E\u65F6\u6D41\uFF08\u542B\u6700\u8FD1 20 \u6761\u56DE\u653E\uFF09\u3002" });
68
+ const log = ui.log({ variant: "conversation", empty: "\u7B49\u5F85\u4E8B\u4EF6\u2026" });
69
+ sheet.body.append(log.el);
70
+ const handle = ctx.stream({
71
+ open() {
72
+ log.append("\u5DF2\u8FDE\u63A5\u4E8B\u4EF6\u901A\u9053", "dim");
73
+ },
74
+ message(raw) {
75
+ let f;
76
+ try {
77
+ f = JSON.parse(raw);
78
+ } catch {
79
+ log.append(raw, "dim");
80
+ return;
81
+ }
82
+ const sign = f.type?.endsWith(".recall") ? "\u21A9" : f.type === "qq.message" ? "\u2190" : "\xB7";
83
+ const tone = f.type?.endsWith(".recall") ? "warn" : "plain";
84
+ const line = `${fmtClock(f.ts)} ${sign} ${f.senderKey ?? ""}: ${f.text ?? ""}`.trim();
85
+ log.append(line, tone);
86
+ },
87
+ close(willRetry) {
88
+ log.append(willRetry ? "\u4E8B\u4EF6\u901A\u9053\u65AD\u5F00\uFF0C\u91CD\u8FDE\u4E2D\u2026" : "\u4E8B\u4EF6\u901A\u9053\u5DF2\u5173\u95ED", willRetry ? "warn" : "dim");
89
+ }
90
+ });
91
+ ctx.root.append(sheet.el);
92
+ return ctx.own(handle);
93
+ }
94
+ };
95
+ var bundle = { panels: { roster: rosterPanel, events: eventsPanel } };
96
+ var client_default = bundle;
97
+ export {
98
+ client_default as default
99
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "cortico-world-qq-better",
3
+ "version": "0.1.0",
4
+ "description": "Cortico world extension: QQ (OneBot v11 / NapCat) adapter ported from fat-fish qq_bot runtime, with both reverse and forward WebSocket modes and incremental Cortico-built-in features (draft-confirm gate, console, env prompt, notices, optional vision).",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "files": [
8
+ "src",
9
+ "dist",
10
+ "README.md",
11
+ "ENV_PROMPT.md"
12
+ ],
13
+ "license": "MIT",
14
+ "keywords": [
15
+ "cortico-world",
16
+ "qq",
17
+ "onebot",
18
+ "napcat"
19
+ ],
20
+ "cortico": {
21
+ "kind": "world",
22
+ "api": 5,
23
+ "label": "QQ_bot",
24
+ "consoleClient": "dist/console.js"
25
+ },
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "dependencies": {
30
+ "ws": "^8.18.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.10.0",
34
+ "@types/ws": "^8.5.13",
35
+ "typescript": "^5.7.0"
36
+ },
37
+ "scripts": {
38
+ "typecheck": "tsc --noEmit -p tsconfig.json",
39
+ "build": "tsc --noEmit -p tsconfig.json",
40
+ "build:console": "node scripts/build-console.mjs",
41
+ "test": "node --test tests/normalize.test.ts tests/config.test.ts"
42
+ }
43
+ }
package/src/config.ts ADDED
@@ -0,0 +1,173 @@
1
+ import type { ConfigGroup } from 'cortico/core/types.ts';
2
+ import type { WorldSection } from 'cortico/world.ts';
3
+
4
+ /** fat-fish qq_bot 的关键参数 + Cortico 内置 world 的可选项,统一为一份配置。 */
5
+ export interface QQWorldConfig {
6
+ /** 连接模式:reverse=Cortico 开 WS 服务端等 NapCat 连(fat-fish 风格);forward=Cortico 连 NapCat 的 wsUrl(内置风格)。 */
7
+ mode: 'reverse' | 'forward';
8
+ /** forward 模式:NapCat 暴露的 WebSocket 地址。 */
9
+ wsUrl: string;
10
+ /** reverse 模式:Cortico 在哪个 host/port/path 监听 NapCat 反向连接。 */
11
+ wsHost: string;
12
+ wsPort: number;
13
+ wsPath: string;
14
+ /** NapCat 接入 Token(OneBot access_token),二选一生效。 */
15
+ token: string;
16
+ /** 监听中的群(fat-fish 的 GROUP_WHITELIST / 内置的 activeGroupIds)。 */
17
+ groups: number[];
18
+ /** 监听中的私聊对方 QQ(activePrivatePeers)。 */
19
+ privates: number[];
20
+ /** 私聊聚合窗口(ms),fat-fish PRIVATE_AGGREGATION_WINDOW。 */
21
+ privateAggregationWindow: number;
22
+ /** 连续消息最大间隔(ms),超出视为新主题(fat-fish MAX_SEQUENCE_GAP)。 */
23
+ maxSequenceGap: number;
24
+ /** 引用回看窗口(秒),fat-fish REPLY_MAX_GAP_SEC。 */
25
+ replyMaxGapSec: number;
26
+ /** 长文本按此字节数强制分句(fat-fish IMAGE_URL_MAX_CHARS 思路复用)。 */
27
+ maxMessageBytes: number;
28
+ /** 合并转发消息最多展开条数。 */
29
+ forwardExpandLimit: number;
30
+ /** 本地表情包图片目录(相对扩展或绝对路径),供发送 `[表情包:文件名]` 标记使用。 */
31
+ emojiDir: string;
32
+ /** 是否把图片作为附件交给模型(需要模型支持图像)。 */
33
+ deliverImageAttachments: boolean;
34
+ /** 发送时按句拆分多条(fat-fish SPLIT_REPLY_BY_SENTENCE)。 */
35
+ splitReplyBySentence: boolean;
36
+ /** 每条消息最多容纳句数(fat-fish SENTENCES_PER_MESSAGE)。 */
37
+ sentencesPerMessage: number;
38
+ /** 逐条发送之间的间隔毫秒(fat-fish SEND_INTERVAL_SECONDS),0 表示不限速。 */
39
+ sendIntervalMs: number;
40
+ /** 被动视觉:自动用 VLM 描述图片(off 则不调用外部 API,仅留占位)。 */
41
+ vision: {
42
+ enabled: boolean;
43
+ endpoint: string;
44
+ apiKeySecret: string;
45
+ model: string;
46
+ maxConcurrent: number;
47
+ maxBytes: number;
48
+ timeoutMs: number;
49
+ };
50
+ /** 时区,用于时间戳与 env 提示。 */
51
+ timezone: string;
52
+ }
53
+
54
+ export const QQ_DEFAULTS: QQWorldConfig = {
55
+ mode: 'reverse',
56
+ wsUrl: 'ws://127.0.0.1:3001',
57
+ wsHost: '0.0.0.0',
58
+ wsPort: 8080,
59
+ wsPath: '/onebot/v11/ws',
60
+ token: '',
61
+ groups: [],
62
+ privates: [],
63
+ privateAggregationWindow: 0,
64
+ maxSequenceGap: 8000,
65
+ replyMaxGapSec: 600,
66
+ maxMessageBytes: 1500,
67
+ forwardExpandLimit: 20,
68
+ emojiDir: 'emoji',
69
+ deliverImageAttachments: true,
70
+ splitReplyBySentence: true,
71
+ sentencesPerMessage: 1,
72
+ sendIntervalMs: 600,
73
+ vision: {
74
+ enabled: false,
75
+ endpoint: 'https://openrouter.ai/api/v1',
76
+ apiKeySecret: 'OPENROUTER_API_KEY',
77
+ model: 'google/gemini-2.5-flash',
78
+ maxConcurrent: 3,
79
+ maxBytes: 8_000_000,
80
+ timeoutMs: 30000,
81
+ },
82
+ timezone: 'Asia/Shanghai',
83
+ };
84
+
85
+ export const QQ_SECRETS = ['QQ_ACCESS_TOKEN', 'OPENROUTER_API_KEY'];
86
+
87
+ /** worlds.qq 配置段:业务参数 + 必需的 enabled 开关。 */
88
+ export type QQConfigSection = QQWorldConfig & WorldSection;
89
+
90
+ export function qqDefaults(): QQConfigSection {
91
+ return { ...QQ_DEFAULTS, enabled: false };
92
+ }
93
+
94
+ /** 控制台可编辑的连接/监听配置组(JSON Schema 形态)。 */
95
+ export const QQ_CONFIG_GROUP: ConfigGroup = {
96
+ id: 'world:qqbot',
97
+ owner: 'world:qqbot',
98
+ schema: {
99
+ type: 'object',
100
+ title: 'QQ · 连接与监听',
101
+ description: 'OneBot v11 / NapCat 连接参数与监听名单;连接类改动需重启生效。',
102
+ properties: {
103
+ 'worlds.qqbot.mode': {
104
+ type: 'string',
105
+ title: '连接模式',
106
+ enum: ['reverse', 'forward'],
107
+ 'x-hot': false,
108
+ description: 'reverse=Cortico 开 WS 服务端等 NapCat 反向连(fat-fish 风格);forward=Cortico 主动连 NapCat 的 wsUrl。',
109
+ },
110
+ 'worlds.qqbot.wsUrl': { type: 'string', title: '正向 WS 地址', 'x-hot': false },
111
+ 'worlds.qqbot.wsHost': { type: 'string', title: '反向监听地址', 'x-hot': false },
112
+ 'worlds.qqbot.wsPort': { type: 'integer', title: '反向监听端口', minimum: 1, maximum: 65535, 'x-hot': false },
113
+ 'worlds.qqbot.wsPath': { type: 'string', title: '反向监听路径', 'x-hot': false },
114
+ 'worlds.qqbot.token': { type: 'string', title: 'OneBot Token', 'x-hot': false },
115
+ 'worlds.qqbot.groups': { type: 'array', title: '监听群', items: { type: 'integer' }, 'x-hot': true, description: '监听的群号列表;改完即时生效,不用重启。' },
116
+ 'worlds.qqbot.privates': { type: 'array', title: '监听私聊', items: { type: 'integer' }, 'x-hot': true, description: '监听的私聊对方 QQ 号;改完即时生效,不用重启。' },
117
+ 'worlds.qqbot.deliverImageAttachments': { type: 'boolean', title: '图片作为模型附件', 'x-hot': true },
118
+ 'worlds.qqbot.emojiDir': { type: 'string', title: '表情包目录', 'x-hot': false, description: '本地表情包图片目录(相对扩展或绝对路径),供发送 [表情包:文件名] 标记。' },
119
+ 'worlds.qqbot.splitReplyBySentence': { type: 'boolean', title: '按句拆分发送', 'x-hot': true, description: '开启后回复按句标拆成多条消息(对齐 fat-fish)。' },
120
+ 'worlds.qqbot.sentencesPerMessage': { type: 'integer', title: '每条句数', minimum: 1, maximum: 10, 'x-hot': true },
121
+ 'worlds.qqbot.sendIntervalMs': { type: 'integer', title: '发送间隔(ms)', minimum: 0, maximum: 5000, 'x-hot': true, description: '逐条发送之间的间隔,0 表示不限速。' },
122
+ 'worlds.qqbot.vision.enabled': { type: 'boolean', title: '开启辅助视觉', 'x-hot': false },
123
+ 'worlds.qqbot.vision.model': { type: 'string', title: 'VLM 模型', 'x-hot': false },
124
+ },
125
+ },
126
+ };
127
+
128
+ /**
129
+ * 原地补全缺省值并保持对象身份:让 World 持有的 this.config 就是 ctx.cfg 的同一引用,
130
+ * 这样控制台的 x-hot 配置改动(groups / privates / 发送节奏等)能即时对 World 生效,无需重启。
131
+ */
132
+ export function normalizeConfig(raw: QQConfigSection): QQWorldConfig {
133
+ const c = raw as unknown as QQWorldConfig & Record<string, unknown>;
134
+ for (const [k, v] of Object.entries(QQ_DEFAULTS)) {
135
+ if (c[k] === undefined) c[k] = v;
136
+ }
137
+ c.groups = Array.isArray(c.groups) ? c.groups.map(Number).filter(Number.isFinite) : [];
138
+ c.privates = Array.isArray(c.privates) ? c.privates.map(Number).filter(Number.isFinite) : [];
139
+ if (typeof c.privateAggregationWindow !== 'number') c.privateAggregationWindow = QQ_DEFAULTS.privateAggregationWindow;
140
+ if (typeof c.maxSequenceGap !== 'number') c.maxSequenceGap = QQ_DEFAULTS.maxSequenceGap;
141
+ if (typeof c.replyMaxGapSec !== 'number') c.replyMaxGapSec = QQ_DEFAULTS.replyMaxGapSec;
142
+ if (typeof c.maxMessageBytes !== 'number') c.maxMessageBytes = QQ_DEFAULTS.maxMessageBytes;
143
+ if (typeof c.forwardExpandLimit !== 'number') c.forwardExpandLimit = QQ_DEFAULTS.forwardExpandLimit;
144
+ if (typeof c.splitReplyBySentence !== 'boolean') c.splitReplyBySentence = true;
145
+ if (typeof c.sentencesPerMessage !== 'number') c.sentencesPerMessage = 1;
146
+ if (typeof c.sendIntervalMs !== 'number') c.sendIntervalMs = QQ_DEFAULTS.sendIntervalMs;
147
+ if (typeof c.deliverImageAttachments !== 'boolean') c.deliverImageAttachments = QQ_DEFAULTS.deliverImageAttachments;
148
+ if (typeof c.emojiDir !== 'string' || !c.emojiDir) c.emojiDir = QQ_DEFAULTS.emojiDir;
149
+ if (typeof c.timezone !== 'string' || !c.timezone) c.timezone = QQ_DEFAULTS.timezone;
150
+ if (typeof c.mode !== 'string') c.mode = 'reverse';
151
+ if (typeof c.token !== 'string') c.token = '';
152
+ if (typeof c.wsUrl !== 'string') c.wsUrl = QQ_DEFAULTS.wsUrl;
153
+ if (typeof c.wsHost !== 'string') c.wsHost = QQ_DEFAULTS.wsHost;
154
+ if (typeof c.wsPort !== 'number') c.wsPort = QQ_DEFAULTS.wsPort;
155
+ if (typeof c.wsPath !== 'string') c.wsPath = QQ_DEFAULTS.wsPath;
156
+ // vision 子对象原地合并,保持引用身份(x-hot 生效)
157
+ c.vision = c.vision ?? {};
158
+ Object.assign(c.vision, { ...QQ_DEFAULTS.vision, ...(c.vision as object) });
159
+ return c;
160
+ }
161
+
162
+ export function parseNumberSeq(v: unknown): number[] {
163
+ if (Array.isArray(v)) return v.map((x) => Number(x)).filter((x) => Number.isFinite(x));
164
+ if (typeof v === 'string') {
165
+ return v
166
+ .split(/[\s,;]+/)
167
+ .map((s) => s.trim())
168
+ .filter(Boolean)
169
+ .map((s) => Number(s))
170
+ .filter((n) => Number.isFinite(n));
171
+ }
172
+ return [];
173
+ }
@@ -0,0 +1,193 @@
1
+ // QQ 扩展控制台面板 bundle(浏览器端,ESM)。
2
+ // 自包含类型,不 import cortico/*,便于 esbuild 独立打包(框架只要求 default 导出 ConsoleClientBundle)。
3
+
4
+ interface ConsoleUi {
5
+ sheet(opts: { title: string; en?: string; desc?: string }): {
6
+ el: HTMLElement;
7
+ body: HTMLElement;
8
+ note: HTMLElement;
9
+ desc: HTMLElement | null;
10
+ };
11
+ rowbar(): HTMLDivElement;
12
+ pill(text: string, tone?: string): HTMLSpanElement;
13
+ placeholder(text: string): HTMLDivElement;
14
+ msgline(text?: string, bad?: boolean): HTMLDivElement;
15
+ table(opts?: { head?: readonly string[]; maxHeight?: string }): {
16
+ el: HTMLDivElement;
17
+ body: HTMLTableSectionElement;
18
+ addRow(cells: readonly (string | number | null | undefined | HTMLElement)[]): HTMLTableRowElement;
19
+ clear(empty?: string): void;
20
+ };
21
+ log(opts?: {
22
+ variant?: string;
23
+ max?: number;
24
+ maxHeight?: string;
25
+ empty?: string;
26
+ }): {
27
+ el: HTMLElement;
28
+ append(line: string, tone?: string): HTMLDivElement;
29
+ clear(): void;
30
+ count: number;
31
+ stuck: boolean;
32
+ scrollToEnd(): void;
33
+ };
34
+ }
35
+
36
+ interface ConsolePanelContext {
37
+ pageId: string;
38
+ panelId: string;
39
+ language: string;
40
+ root: HTMLElement;
41
+ signal: AbortSignal;
42
+ invoke<T = unknown>(method: string, args?: unknown[]): Promise<T>;
43
+ stream(handlers: {
44
+ message: (text: string) => void;
45
+ open?: () => void;
46
+ close?: (willRetry: boolean) => void;
47
+ }): { dispose(): void };
48
+ own<T>(d: T): T;
49
+ ui: ConsoleUi;
50
+ }
51
+
52
+ interface ConsolePanel {
53
+ mount(ctx: ConsolePanelContext): unknown;
54
+ }
55
+
56
+ interface ConsoleClientBundle {
57
+ panels: Record<string, ConsolePanel>;
58
+ }
59
+
60
+ interface RosterConv {
61
+ address: string;
62
+ label?: string | null;
63
+ kind: 'group' | 'private' | string;
64
+ active?: boolean;
65
+ members?: number;
66
+ lastMessageAt?: string | null;
67
+ }
68
+
69
+ interface RosterResult {
70
+ selfId?: string | number | null;
71
+ mode?: string;
72
+ groups?: number[];
73
+ privates?: number[];
74
+ convs?: RosterConv[];
75
+ }
76
+
77
+ interface EventFrame {
78
+ ts?: number;
79
+ type?: string;
80
+ senderKey?: string | null;
81
+ text?: string | null;
82
+ }
83
+
84
+ function fmtClock(ts: unknown): string {
85
+ const n = typeof ts === 'number' ? ts : typeof ts === 'string' ? Date.parse(ts) : NaN;
86
+ if (!Number.isFinite(n)) return '';
87
+ const d = new Date(n);
88
+ if (Number.isNaN(d.getTime())) return '';
89
+ const p = (x: number) => String(x).padStart(2, '0');
90
+ return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
91
+ }
92
+
93
+ function fmtTime(ts: unknown): string {
94
+ const n = typeof ts === 'number' ? ts : typeof ts === 'string' ? Date.parse(ts) : NaN;
95
+ if (!Number.isFinite(n)) return '—';
96
+ const d = new Date(n);
97
+ if (Number.isNaN(d.getTime())) return '—';
98
+ return `${d.getMonth() + 1}/${d.getDate()} ${fmtClock(n)}`;
99
+ }
100
+
101
+ const rosterPanel: ConsolePanel = {
102
+ async mount(ctx) {
103
+ const ui = ctx.ui;
104
+ const sheet = ui.sheet({ title: '监听名单', en: 'roster', desc: '当前监听的群聊与私聊,及最近消息时间。' });
105
+
106
+ let data: RosterResult;
107
+ try {
108
+ data = await ctx.invoke<RosterResult>('getRoster');
109
+ } catch (err) {
110
+ sheet.body.appendChild(ui.msgline(`读取监听名单失败: ${(err as Error).message}`, true));
111
+ ctx.root.append(sheet.el);
112
+ return;
113
+ }
114
+ data ??= {};
115
+ const groups = data.groups ?? [];
116
+ const privates = data.privates ?? [];
117
+ const convs = data.convs ?? [];
118
+
119
+ const bar = ui.rowbar();
120
+ bar.append(
121
+ ui.pill(`本号 ${data.selfId ?? '?'}`, 'plain'),
122
+ ui.pill(`模式 ${data.mode ?? '?'}`, 'plain'),
123
+ ui.pill(`群 ${groups.length}`, 'plain'),
124
+ ui.pill(`私聊 ${privates.length}`, 'plain'),
125
+ );
126
+ sheet.body.append(bar);
127
+
128
+ if (convs.length === 0) {
129
+ sheet.body.append(ui.placeholder('还没有任何会话。让 bot 收到消息或把它加入监听名单后,这里会出现记录。'));
130
+ } else {
131
+ const table = ui.table({
132
+ head: ['类型', '名称', '账号', '人数', '已激活', '最近消息'],
133
+ maxHeight: 'calc(100vh - 320px)',
134
+ });
135
+ for (const c of convs) {
136
+ const kindLabel = c.kind === 'group' ? '群' : c.kind === 'private' ? '私聊' : String(c.kind);
137
+ table.addRow([
138
+ kindLabel,
139
+ c.label ?? '',
140
+ String(c.address),
141
+ String(c.members ?? 0),
142
+ c.active ? '是' : '否',
143
+ fmtTime(c.lastMessageAt),
144
+ ]);
145
+ }
146
+ sheet.body.append(table.el);
147
+ }
148
+
149
+ ctx.root.append(sheet.el);
150
+ },
151
+ };
152
+
153
+ const eventsPanel: ConsolePanel = {
154
+ mount(ctx) {
155
+ const ui = ctx.ui;
156
+ const sheet = ui.sheet({ title: '实时事件', en: 'events', desc: 'QQ 消息与通知的实时流(含最近 20 条回放)。' });
157
+
158
+ const log = ui.log({ variant: 'conversation', empty: '等待事件…' });
159
+ sheet.body.append(log.el);
160
+
161
+ const handle = ctx.stream({
162
+ open() {
163
+ log.append('已连接事件通道', 'dim');
164
+ },
165
+ message(raw) {
166
+ let f: EventFrame;
167
+ try {
168
+ f = JSON.parse(raw) as EventFrame;
169
+ } catch {
170
+ log.append(raw, 'dim');
171
+ return;
172
+ }
173
+ const sign = f.type?.endsWith('.recall')
174
+ ? '↩'
175
+ : f.type === 'qq.message'
176
+ ? '←'
177
+ : '·';
178
+ const tone = f.type?.endsWith('.recall') ? 'warn' : 'plain';
179
+ const line = `${fmtClock(f.ts)} ${sign} ${f.senderKey ?? ''}: ${f.text ?? ''}`.trim();
180
+ log.append(line, tone);
181
+ },
182
+ close(willRetry) {
183
+ log.append(willRetry ? '事件通道断开,重连中…' : '事件通道已关闭', willRetry ? 'warn' : 'dim');
184
+ },
185
+ });
186
+
187
+ ctx.root.append(sheet.el);
188
+ return ctx.own(handle);
189
+ },
190
+ };
191
+
192
+ const bundle: ConsoleClientBundle = { panels: { roster: rosterPanel, events: eventsPanel } };
193
+ export default bundle;
@@ -0,0 +1,12 @@
1
+ import type { WorldDefinition } from 'cortico/world.ts';
2
+ import { QQWorld } from './world.ts';
3
+ import { qqDefaults, type QQConfigSection } from './config.ts';
4
+
5
+ export const definition: WorldDefinition<QQConfigSection> = {
6
+ id: 'qqbot',
7
+ label: 'QQ 群聊',
8
+ defaults: qqDefaults,
9
+ create(ctx) {
10
+ return new QQWorld(ctx.cfg, ctx.packageDir, ctx.dataDir);
11
+ },
12
+ };