qq-bot-roost 0.9.10 → 1.0.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/README.md CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
9
  <img src="https://img.shields.io/badge/Node.js-22.13%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22.13+">
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
- <img src="https://img.shields.io/badge/tests-679%20passed-brightgreen" alt="测试 679 项">
11
+ <img src="https://img.shields.io/badge/tests-737%20passed-brightgreen" alt="测试 737 项">
12
12
  <img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
13
- <img src="https://img.shields.io/badge/version-0.9.10-blueviolet" alt="版本 0.9.10">
13
+ <img src="https://img.shields.io/badge/version-1.0.0-blueviolet" alt="版本 1.0.0">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
15
  <a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
16
16
 
@@ -71,6 +71,14 @@
71
71
  - **素材缓存**:同一个图片 url 两天内只上传一次,省富媒体频控和流量
72
72
  - **中间件管道**:去重、白名单、限流、命令冷却都可插拔
73
73
 
74
+ **身份与记忆**(v1.0.0)
75
+
76
+ - **QQ号 / 群号 ↔ openid 绑定**:换机器人(换 AppID)之后 openid 会变,而记忆是按 openid 存的。绑定之后,读记忆会把同一个号码名下的所有键一起读——**存量数据一个字节不动**
77
+ - **面板「身份」页**:绑定、追加、改绑、停用;未识别的人也能看到
78
+ - **群内指令(0 / 1 级)**:`/绑定群号 <群号>`、`/绑定成员 @某人 <QQ号>`。只绑当前群和当前群里被 @ 的人,冲突不强制改绑
79
+ - **消息带四个字段**:`uin` / `groupUin` / `userAliases` / `groupAliases`。插件用 `ctx.identity` 只读视图查,绑定表由框架维护
80
+ - 配套升级:`dsh-roost` 1.0.0(别名合并读取 + 账号迁移页)、`ai-chat` 4.4.1(透传字段)。**三个一起升**,少一个号码就传不到 DSH
81
+
74
82
  **连接与容错**
75
83
 
76
84
  - **两种收事件方式**:WebSocket 长连接(本地默认)或 **Webhook 回调**(公网 / Serverless / 容器)
@@ -302,6 +310,8 @@ node src/main.js --mock --serve
302
310
  | `/enable` `/disable` `/reload [id]` | 插件管理(需全局管理员) |
303
311
  | `/群插件` | 看 / 改**这个群**能用哪些插件(群管理;面板「群组 → 插件」也能改) |
304
312
  | `/whoami` | 查自己的 openid / 群 openid |
313
+ | `/绑定群号 <群号>` | 把**当前群**绑到群号(仅 0 / 1 级;冲突不强制改绑) |
314
+ | `/绑定成员 @某人 <QQ号>` | 把**当前群里被 @ 的人**绑到 QQ号(仅 0 / 1 级) |
305
315
  | `/claim <认领码>` | 认领主人身份(`owners` 为空时可用) |
306
316
  | `/ping` | 连通性测试 |
307
317
 
@@ -368,6 +378,7 @@ export default {
368
378
  | `ctx.storage.get/set/all` | 插件私有存档 |
369
379
  | `ctx.config` / `ctx.setConfig()` | 插件配置读写 |
370
380
  | `ctx.can` / `ctx.levelOf` / `ctx.scopeOf` | 权限判断 |
381
+ | `ctx.identity` | **只读**身份视图:`openidOf(uin)` / `uinOf(openid)` / `aliasesOf(uin)` / `groupOf(openid)`。绑定表由框架维护,插件不能写 |
371
382
  | `ctx.registerTool(工具)` | 注册一个 AI 可调用的工具(跨插件,0.8.6+),返回注销函数 |
372
383
  | `ctx.tools()` / `ctx.callTool(名, 参数)` | 列出 / 调用别插件注册的工具 |
373
384
  | `msg.reply` / `push` / `replyMarkdown` / `replyImage` / … | 各种发送方式 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.9.10",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -29,9 +29,9 @@
29
29
  "start": "node src/main.js",
30
30
  "mock": "node src/main.js --mock",
31
31
  "check": "node src/main.js --check",
32
- "test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs",
32
+ "test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node --test test/identity.mjs && node --test test/identity-commands.mjs",
33
33
  "test:e2e": "node test/e2e.mjs",
34
- "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node test/e2e.mjs && node test/live-driver.mjs",
34
+ "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs && node --test test/group-name.mjs && node --test test/identity.mjs && node --test test/identity-commands.mjs && node test/e2e.mjs && node test/live-driver.mjs",
35
35
  "check:secrets": "node scripts/check-secrets.mjs",
36
36
  "create": "node scripts/create-plugin.mjs",
37
37
  "test:plugin": "node scripts/test-plugin.mjs",
package/src/api.js CHANGED
@@ -284,14 +284,22 @@ export class QQApi {
284
284
  const body = { file_type: fileType, srv_send_msg: false };
285
285
  if (url) body.url = url;
286
286
  if (fileData) body.file_data = fileData;
287
+ /**
288
+ * `file_name` **不只分片合并要**。
289
+ *
290
+ * 官方字段说明:「文件名(分片上传或者二进制上传时必填)」。
291
+ * 以前只在 `upload_id` 那条路带上,于是小文件走 base64 直传时是**无名文件**——
292
+ * QQ 客户端拿不到名字,显示出来就没有扩展名(`报告` 而不是 `报告.docx`),
293
+ * 对方还得自己猜怎么打开。2026-11-02 线上真踩过。
294
+ *
295
+ * URL 上传不用带:那种路径平台自己下载转存,名字从 URL 里取。
296
+ */
297
+ if (fileName) body.file_name = fileName;
287
298
  /**
288
299
  * 分片合并:`upload_id` 一进,服务端就走「合并已上传分片」那条路,
289
- * `url` 可以为空。但 `file_name` 这条路径**必填**(官方字段说明)。
300
+ * `url` 可以为空。
290
301
  */
291
- if (uploadId) {
292
- body.upload_id = uploadId;
293
- if (fileName) body.file_name = fileName;
294
- }
302
+ if (uploadId) body.upload_id = uploadId;
295
303
  const r = await this.#request('POST', path, body);
296
304
 
297
305
  if (cacheKey && r?.file_info) {
@@ -348,6 +356,11 @@ export class QQApi {
348
356
  const up = await this.uploadMedia(scene, target, {
349
357
  fileType,
350
358
  fileData: buf.toString('base64'),
359
+ /**
360
+ * 二进制直传**必须带文件名**(官方字段说明:分片上传或二进制上传时必填)。
361
+ * 少了它 QQ 侧就是个无名文件,客户端连扩展名都显示不出来。
362
+ */
363
+ fileName: filePath.split(/[\\/]/).pop() || 'file',
351
364
  reuse: false,
352
365
  });
353
366
  if (cacheKey && up?.file_info) {
@@ -967,7 +980,21 @@ export class QQApi {
967
980
  content_raw: String(p.contentRaw ?? ''),
968
981
  };
969
982
  if (p.streamMsgId) payload.stream_msg_id = p.streamMsgId;
970
- if (p.msgId) payload.msg_id = p.msgId;
983
+ if (p.msgId) {
984
+ payload.msg_id = p.msgId;
985
+ /**
986
+ * `msg_seq` 必须显式给,而且**所有分片用同一个值**。
987
+ *
988
+ * 流式消息是「一条消息在长」,不是多条——官方示例里首片、续片、结束片
989
+ * 都是同一个 `msg_seq`。不传的话服务端按 1 处理,而我们自己的普通回复
990
+ * 也从 1 开始递增,于是「先流式、再发图/文件」会撞
991
+ * `40054005 消息被去重`。
992
+ *
993
+ * 取值由调用方先算好传进来(`replyStream` 会在首片前调 `takeSeq`)。
994
+ */
995
+ if (p.msgSeq != null) payload.msg_seq = Number(p.msgSeq);
996
+ else payload.msg_seq = this.#nextSeq(p.msgId);
997
+ }
971
998
  if (p.eventId) payload.event_id = p.eventId;
972
999
  if (p.isWakeup) payload.is_wakeup = true;
973
1000
  return this.#request('POST', `/v2/users/${enc(openid)}/stream_messages`, payload);
@@ -1198,6 +1225,18 @@ export class QQApi {
1198
1225
 
1199
1226
  /* ---------------- 内部 ---------------- */
1200
1227
 
1228
+ /**
1229
+ * 先占一个 `msg_seq`,供流式消息这种「一条消息发好几次」的场景用。
1230
+ *
1231
+ * 为什么要有:流式接口不走 `#payload`,所以以前完全不占序号——首片按默认 1 算,
1232
+ * 而我们自己后续的普通回复也从 1 开始递增,两边撞上就是
1233
+ * `40054005 消息被去重`(用户那边表现为「发了但没出现」)。
1234
+ * 流式所有分片共用这一个值(一条消息一个序号)。
1235
+ */
1236
+ takeSeq(msgId) {
1237
+ return this.#nextSeq(msgId);
1238
+ }
1239
+
1201
1240
  #payload(opts) {
1202
1241
  return buildSendPayload(opts, (id) => this.#nextSeq(id));
1203
1242
  }
package/src/bot.js CHANGED
@@ -20,6 +20,7 @@ import { createReach } from './reach.js';
20
20
  import { createLedger } from './ledger.js';
21
21
  import { createGate } from './gate.js';
22
22
  import { createDeliveryLedger } from './delivery.js';
23
+ import { sharedIdentity } from './identity.js';
23
24
 
24
25
  /**
25
26
  * 一个机器人的完整运行时。
@@ -68,6 +69,14 @@ export class BotRuntime {
68
69
  this.gateway = null;
69
70
  this.registry = new Registry();
70
71
  this.groups = new GroupStore(this.paths.groups);
72
+ /**
73
+ * 身份对照表(QQ号 / 群号 ↔ openid)。
74
+ *
75
+ * **进程级单例、不按机器人拆**——它的全部意义就是横跨机器人。
76
+ * 在这里建而不是在 `init()` 里:`buildMessage()` 要用它,
77
+ * 不能依赖「init 已经跑过」。真正的读盘在 `init()` 里 `await load()`。
78
+ */
79
+ this.identity = sharedIdentity();
71
80
 
72
81
  /**
73
82
  * 最近说过话的人:openid -> { openid, name, chatId, scope, at }
@@ -166,13 +175,21 @@ export class BotRuntime {
166
175
  });
167
176
  }
168
177
 
178
+ /**
179
+ * 身份表要在**插件加载和收消息之前**读好。
180
+ *
181
+ * 晚一步的话,启动后第一批消息就没有 `uin`,AI 会把老朋友当陌生人;
182
+ * 插件 setup 里想用 `ctx.identity` 也会查到空。
183
+ * 载入失败不拦启动——`load()` 内部已经把坏文件降级成空表并留了档。
184
+ */
185
+ await this.identity.load().catch((e) => this.log.warn(`身份表载入失败:${e.message}`));
186
+
169
187
  if (this.mock) {
170
188
  this.api = this.makeMockApi ? this.makeMockApi(this) : null;
171
189
  } else {
172
190
  this.tokens = new TokenManager(cfg);
173
191
  this.api = new QQApi({ tokenManager: this.tokens, baseUrl: cfg.baseUrl, metrics: this.metrics });
174
192
  }
175
-
176
193
  this.outbound = new Outbound({
177
194
  api: this.api,
178
195
  cfg,
@@ -233,6 +250,8 @@ export class BotRuntime {
233
250
  reach: this.reach,
234
251
  ledger: this.ledger,
235
252
  gate: this.gate,
253
+ /** 身份对照表:插件通过 ctx.identity 只读查询(QQ号 → openid) */
254
+ identity: this.identity,
236
255
  isOwner: (uid) => cfg.owners.includes(uid),
237
256
  // 插件声明了 frameworkVersion 就做兼容校验;作者本地调试可以关掉
238
257
  ignoreCompat: this.globalCfg.plugins?.ignoreFrameworkVersion === true,
@@ -770,7 +789,7 @@ export class BotRuntime {
770
789
  }
771
790
 
772
791
  /* ---- 消息事件 ---- */
773
- const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session });
792
+ const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session, identity: this.identity });
774
793
 
775
794
  /**
776
795
  * 群名不在官方事件里(只有 `group_openid`),但框架有缓存。
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 群里的绑定指令(内核命令的实现)。
3
+ *
4
+ * ## 为什么单独一个文件
5
+ *
6
+ * `main.js` 里的命令 handler 拿不到可测的上下文(registry / roles / cfg / 真消息),
7
+ * 逻辑写在里面只能靠端到端测。这里只做「消息 → 结果文本」的纯逻辑,
8
+ * 权限由调用方算好传进来(`allowed`),身份表通过参数注入。
9
+ *
10
+ * ## 三条硬规矩
11
+ *
12
+ * 1. **只绑当前群、以及当前群里被 @ 的人。** 不接受任何「把别的群的 openid
13
+ * 绑过来」的参数——群里发一句话就能改别人身份的入口,范围必须收到最小。
14
+ * 2. **不做 force。** 冲突就停,让用户去面板确认。群里一句话把别人的身份
15
+ * 改掉,代价比多点两下鼠标高得多。
16
+ * 3. **永远返回文本,不抛。** 聊天命令抛异常等于用户什么都收不到,
17
+ * 而发指令的人只会以为「机器人没反应」。
18
+ */
19
+
20
+ import { isValidUin } from './identity.js';
21
+
22
+ /** 参数里可能混着 `<@openid>` 这样的 token,取**最后一个**合法号码 */
23
+ export function parseTargetUin(args = []) {
24
+ const list = Array.isArray(args) ? args : [];
25
+ for (let i = list.length - 1; i >= 0; i -= 1) {
26
+ const v = String(list[i] ?? '').trim();
27
+ if (isValidUin(v)) return v;
28
+ }
29
+ return '';
30
+ }
31
+
32
+ /** 被 @ 的人里第一个不是机器人的 */
33
+ export function pickMember(mentions = []) {
34
+ for (const m of Array.isArray(mentions) ? mentions : []) {
35
+ if (!m || m.isBot) continue;
36
+ const id = String(m.id ?? '').trim();
37
+ if (id) return { id, name: String(m.name || '') };
38
+ }
39
+ return null;
40
+ }
41
+
42
+ const NEED_ADMIN = '只有主人和管理员(0 / 1 级)能改绑定。';
43
+ const ONLY_GROUP = '这个指令只能在群里用。';
44
+
45
+ function bindErrorText(e) {
46
+ if (e?.code === 'CONFLICT') {
47
+ return `${e.message}。要改绑请去 Roost 面板 →「身份」页,群里不做强制改绑。`;
48
+ }
49
+ if (e?.code === 'INVALID') return `绑定失败:${e.message}`;
50
+ return `绑定失败:${e?.message || e}`;
51
+ }
52
+
53
+ /**
54
+ * `绑定群号 <群号>`:把**当前群**的 openid 绑到群号。
55
+ */
56
+ export async function runBindGroup({ msg, identity, botId = '', allowed = false, log = () => {} } = {}) {
57
+ if (msg?.kind !== 'group') return { ok: false, text: ONLY_GROUP };
58
+ if (!allowed) return { ok: false, text: NEED_ADMIN };
59
+
60
+ const uin = parseTargetUin(msg.args);
61
+ if (!uin) return { ok: false, text: '用法:绑定群号 763366431(5–12 位数字,首位不能是 0)' };
62
+
63
+ /** 群聊时 `chatId` 就是群的 openid;只认这一个来源 */
64
+ const openid = String(msg.chatId || '').trim();
65
+ if (!openid) return { ok: false, text: '拿不到本群的 openid,让群里有人先说句话再试。' };
66
+
67
+ try {
68
+ await identity.bind({ kind: 'group', uin, openid, botId });
69
+ } catch (e) {
70
+ return { ok: false, text: bindErrorText(e) };
71
+ }
72
+ log(`绑定指令:${msg.userId} 在 ${openid} 把本群绑到群号 ${uin}`);
73
+ return {
74
+ ok: true,
75
+ text: `已把本群绑到群号 ${uin}。以后换机器人,新群的 openid 也要绑到同一个群号。`,
76
+ };
77
+ }
78
+
79
+ /**
80
+ * `绑定成员 @某人 <QQ号>`:把**当前群里被 @ 的那个人**绑到 QQ号。
81
+ */
82
+ export async function runBindUser({ msg, identity, botId = '', allowed = false, log = () => {} } = {}) {
83
+ if (msg?.kind !== 'group') return { ok: false, text: ONLY_GROUP };
84
+ if (!allowed) return { ok: false, text: NEED_ADMIN };
85
+
86
+ const member = pickMember(msg.mentions);
87
+ if (!member) return { ok: false, text: '用法:绑定成员 @某人 10001(先 @ 要绑的人,再写 QQ号)' };
88
+
89
+ const uin = parseTargetUin(msg.args);
90
+ if (!uin) return { ok: false, text: '缺 QQ号。用法:绑定成员 @某人 10001' };
91
+
92
+ try {
93
+ await identity.bind({ kind: 'user', uin, openid: member.id, botId });
94
+ } catch (e) {
95
+ return { ok: false, text: bindErrorText(e) };
96
+ }
97
+ log(`绑定指令:${msg.userId} 在 ${msg.chatId} 把 ${member.id} 绑到 QQ号 ${uin}`);
98
+ return { ok: true, text: `已把 ${member.name ? `@${member.name} ` : '这个人'}绑到 QQ号 ${uin}。` };
99
+ }