@tencent-connect/openclaw-qqbot 1.7.1 → 2.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.
Files changed (283) hide show
  1. package/README.md +206 -4
  2. package/README.zh.md +207 -4
  3. package/bin/qqbot-cli.js +5 -2
  4. package/dist/index.cjs +15549 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +890 -0
  7. package/index.ts +56 -18
  8. package/openclaw.plugin.json +19 -3
  9. package/package.json +14 -23
  10. package/preload.cjs +17 -14
  11. package/scripts/link-sdk-core.cjs +133 -50
  12. package/scripts/upgrade-via-npm.sh +400 -73
  13. package/scripts/upgrade-via-source.sh +126 -89
  14. package/skills/qqbot-channel/SKILL.md +34 -12
  15. package/src/adapter/contract.ts +69 -0
  16. package/src/adapter/gateway.ts +61 -0
  17. package/src/adapter/index.ts +15 -0
  18. package/src/adapter/lint.ts +144 -0
  19. package/src/adapter/media.ts +112 -0
  20. package/src/adapter/pairing.ts +99 -0
  21. package/src/adapter/resolve.ts +333 -0
  22. package/src/adapter/setup.ts +55 -0
  23. package/src/adapter/webhook.ts +248 -0
  24. package/src/adapter/workspace.ts +29 -0
  25. package/src/bot-instance.ts +60 -0
  26. package/src/channel.ts +202 -413
  27. package/src/commands/bot-approve.ts +143 -0
  28. package/src/commands/bot-clear-storage.ts +114 -0
  29. package/src/commands/bot-group-always.ts +62 -0
  30. package/src/commands/bot-help.ts +40 -0
  31. package/src/commands/bot-logs.ts +248 -0
  32. package/src/commands/bot-me.ts +18 -0
  33. package/src/commands/bot-pairing.ts +57 -0
  34. package/src/commands/bot-ping.ts +33 -0
  35. package/src/commands/bot-streaming.ts +55 -0
  36. package/src/commands/bot-upgrade.ts +56 -0
  37. package/src/commands/bot-version.ts +41 -0
  38. package/src/commands/config-util.ts +96 -0
  39. package/src/commands/index.ts +49 -0
  40. package/src/config.ts +59 -19
  41. package/src/dispatch/body-assembler.ts +356 -0
  42. package/src/dispatch/ctx-builder.ts +110 -0
  43. package/src/dispatch/dispatch.ts +352 -0
  44. package/src/dispatch/envelope-builder.ts +112 -0
  45. package/src/dispatch/index.ts +2 -0
  46. package/src/{approval-handler.ts → features/approval-handler.ts} +40 -94
  47. package/src/features/approval-utils.ts +41 -0
  48. package/src/features/credential-backup.ts +82 -0
  49. package/src/features/history-store.ts +19 -0
  50. package/src/features/msgid-cache.ts +55 -0
  51. package/src/features/onboarding.ts +38 -0
  52. package/src/{proactive.ts → features/proactive.ts} +68 -49
  53. package/src/features/ref-index-store.ts +282 -0
  54. package/src/{update-checker.ts → features/update-checker.ts} +15 -35
  55. package/src/gateway/event-handlers.ts +241 -0
  56. package/src/gateway/index.ts +3 -0
  57. package/src/gateway/lifecycle.ts +221 -0
  58. package/src/gateway/middleware-setup.ts +139 -0
  59. package/src/gateway/qqbot-gateway.ts +342 -0
  60. package/src/middleware/access-control.ts +130 -0
  61. package/src/middleware/attachment.ts +314 -0
  62. package/src/middleware/policy-injector.ts +66 -0
  63. package/src/openclaw-plugin-sdk.d.ts +81 -10
  64. package/src/outbound/cron-scheduler.ts +130 -0
  65. package/src/outbound/debounce.ts +102 -0
  66. package/src/outbound/deliver-pipeline.ts +235 -0
  67. package/src/outbound/image-size.ts +123 -0
  68. package/src/outbound/index.ts +3 -0
  69. package/src/outbound/local-file-router.ts +145 -0
  70. package/src/outbound/media-send.ts +377 -0
  71. package/src/outbound/outbound-service.ts +222 -0
  72. package/src/outbound/reply-limiter.ts +128 -0
  73. package/src/outbound/sanitize.ts +32 -0
  74. package/src/outbound/streaming-controller.ts +228 -0
  75. package/src/outbound/target.ts +63 -0
  76. package/src/outbound/tts-provider.ts +84 -0
  77. package/src/request-context.ts +9 -8
  78. package/src/runtime.ts +31 -6
  79. package/src/setup/account-key.ts +41 -0
  80. package/src/setup/finalize.ts +110 -0
  81. package/src/setup/login.ts +197 -0
  82. package/src/setup/surface.ts +40 -0
  83. package/src/tools/platform.ts +149 -0
  84. package/src/tools/remind.ts +1 -1
  85. package/src/types-augment.d.ts +54 -0
  86. package/src/types.ts +71 -8
  87. package/src/typings/openclaw-webhook-ingress.d.ts +66 -0
  88. package/src/utils/mention.ts +52 -0
  89. package/src/utils/pkg-version.ts +84 -47
  90. package/src/utils/platform.ts +31 -7
  91. package/src/utils/plugin-logger.ts +104 -0
  92. package/src/utils/ssrf-guard.ts +32 -2
  93. package/src/utils/stt.ts +137 -0
  94. package/src/utils/voice-text.ts +61 -0
  95. package/tsconfig.json +2 -1
  96. package/tsup.config.ts +57 -0
  97. package/dist/index.d.ts +0 -17
  98. package/dist/index.js +0 -26
  99. package/dist/src/admin-resolver.d.ts +0 -33
  100. package/dist/src/admin-resolver.js +0 -157
  101. package/dist/src/api.d.ts +0 -307
  102. package/dist/src/api.js +0 -910
  103. package/dist/src/approval-handler.d.ts +0 -47
  104. package/dist/src/approval-handler.js +0 -372
  105. package/dist/src/channel.d.ts +0 -29
  106. package/dist/src/channel.js +0 -528
  107. package/dist/src/config.d.ts +0 -56
  108. package/dist/src/config.js +0 -278
  109. package/dist/src/credential-backup.d.ts +0 -31
  110. package/dist/src/credential-backup.js +0 -66
  111. package/dist/src/deliver-debounce.d.ts +0 -74
  112. package/dist/src/deliver-debounce.js +0 -174
  113. package/dist/src/gateway.d.ts +0 -18
  114. package/dist/src/gateway.js +0 -2063
  115. package/dist/src/group-history.d.ts +0 -136
  116. package/dist/src/group-history.js +0 -226
  117. package/dist/src/image-server.d.ts +0 -87
  118. package/dist/src/image-server.js +0 -570
  119. package/dist/src/inbound-attachments.d.ts +0 -60
  120. package/dist/src/inbound-attachments.js +0 -248
  121. package/dist/src/known-users.d.ts +0 -100
  122. package/dist/src/known-users.js +0 -263
  123. package/dist/src/message-gating.d.ts +0 -53
  124. package/dist/src/message-gating.js +0 -107
  125. package/dist/src/message-queue.d.ts +0 -91
  126. package/dist/src/message-queue.js +0 -257
  127. package/dist/src/onboarding.d.ts +0 -10
  128. package/dist/src/onboarding.js +0 -203
  129. package/dist/src/outbound-deliver.d.ts +0 -48
  130. package/dist/src/outbound-deliver.js +0 -392
  131. package/dist/src/outbound.d.ts +0 -220
  132. package/dist/src/outbound.js +0 -975
  133. package/dist/src/proactive.d.ts +0 -170
  134. package/dist/src/proactive.js +0 -399
  135. package/dist/src/ref-index-store.d.ts +0 -101
  136. package/dist/src/ref-index-store.js +0 -298
  137. package/dist/src/reply-dispatcher.d.ts +0 -35
  138. package/dist/src/reply-dispatcher.js +0 -311
  139. package/dist/src/request-context.d.ts +0 -25
  140. package/dist/src/request-context.js +0 -37
  141. package/dist/src/runtime.d.ts +0 -3
  142. package/dist/src/runtime.js +0 -13
  143. package/dist/src/session-store.d.ts +0 -52
  144. package/dist/src/session-store.js +0 -254
  145. package/dist/src/slash-commands.d.ts +0 -82
  146. package/dist/src/slash-commands.js +0 -2117
  147. package/dist/src/startup-greeting.d.ts +0 -30
  148. package/dist/src/startup-greeting.js +0 -97
  149. package/dist/src/streaming.d.ts +0 -247
  150. package/dist/src/streaming.js +0 -899
  151. package/dist/src/stt.d.ts +0 -21
  152. package/dist/src/stt.js +0 -70
  153. package/dist/src/tools/channel.d.ts +0 -16
  154. package/dist/src/tools/channel.js +0 -234
  155. package/dist/src/tools/remind.d.ts +0 -2
  156. package/dist/src/tools/remind.js +0 -256
  157. package/dist/src/types.d.ts +0 -443
  158. package/dist/src/types.js +0 -22
  159. package/dist/src/typing-keepalive.d.ts +0 -27
  160. package/dist/src/typing-keepalive.js +0 -64
  161. package/dist/src/update-checker.d.ts +0 -36
  162. package/dist/src/update-checker.js +0 -171
  163. package/dist/src/utils/audio-convert.d.ts +0 -98
  164. package/dist/src/utils/audio-convert.js +0 -755
  165. package/dist/src/utils/chunked-upload.d.ts +0 -68
  166. package/dist/src/utils/chunked-upload.js +0 -341
  167. package/dist/src/utils/file-utils.d.ts +0 -61
  168. package/dist/src/utils/file-utils.js +0 -172
  169. package/dist/src/utils/image-size.d.ts +0 -51
  170. package/dist/src/utils/image-size.js +0 -234
  171. package/dist/src/utils/media-send.d.ts +0 -158
  172. package/dist/src/utils/media-send.js +0 -502
  173. package/dist/src/utils/media-tags.d.ts +0 -14
  174. package/dist/src/utils/media-tags.js +0 -165
  175. package/dist/src/utils/payload.d.ts +0 -112
  176. package/dist/src/utils/payload.js +0 -186
  177. package/dist/src/utils/pkg-version.d.ts +0 -5
  178. package/dist/src/utils/pkg-version.js +0 -61
  179. package/dist/src/utils/platform.d.ts +0 -137
  180. package/dist/src/utils/platform.js +0 -390
  181. package/dist/src/utils/ssrf-guard.d.ts +0 -25
  182. package/dist/src/utils/ssrf-guard.js +0 -91
  183. package/dist/src/utils/text-parsing.d.ts +0 -31
  184. package/dist/src/utils/text-parsing.js +0 -74
  185. package/dist/src/utils/upload-cache.d.ts +0 -34
  186. package/dist/src/utils/upload-cache.js +0 -93
  187. package/node_modules/@eshaz/web-worker/LICENSE +0 -201
  188. package/node_modules/@eshaz/web-worker/README.md +0 -134
  189. package/node_modules/@eshaz/web-worker/browser.js +0 -17
  190. package/node_modules/@eshaz/web-worker/cjs/browser.js +0 -16
  191. package/node_modules/@eshaz/web-worker/cjs/node.js +0 -219
  192. package/node_modules/@eshaz/web-worker/index.d.ts +0 -4
  193. package/node_modules/@eshaz/web-worker/node.js +0 -223
  194. package/node_modules/@eshaz/web-worker/package.json +0 -54
  195. package/node_modules/@wasm-audio-decoders/common/index.js +0 -5
  196. package/node_modules/@wasm-audio-decoders/common/package.json +0 -36
  197. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +0 -231
  198. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +0 -129
  199. package/node_modules/@wasm-audio-decoders/common/src/puff/README +0 -67
  200. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +0 -31
  201. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +0 -863
  202. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +0 -35
  203. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +0 -3
  204. package/node_modules/@wasm-audio-decoders/common/types.d.ts +0 -7
  205. package/node_modules/mpg123-decoder/README.md +0 -265
  206. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +0 -185
  207. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +0 -1
  208. package/node_modules/mpg123-decoder/index.js +0 -8
  209. package/node_modules/mpg123-decoder/package.json +0 -58
  210. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +0 -464
  211. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +0 -200
  212. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +0 -21
  213. package/node_modules/mpg123-decoder/types.d.ts +0 -30
  214. package/node_modules/silk-wasm/LICENSE +0 -21
  215. package/node_modules/silk-wasm/README.md +0 -85
  216. package/node_modules/silk-wasm/lib/index.cjs +0 -16
  217. package/node_modules/silk-wasm/lib/index.d.ts +0 -70
  218. package/node_modules/silk-wasm/lib/index.mjs +0 -16
  219. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  220. package/node_modules/silk-wasm/lib/utils.d.ts +0 -4
  221. package/node_modules/silk-wasm/package.json +0 -39
  222. package/node_modules/simple-yenc/.github/FUNDING.yml +0 -1
  223. package/node_modules/simple-yenc/.prettierignore +0 -1
  224. package/node_modules/simple-yenc/LICENSE +0 -7
  225. package/node_modules/simple-yenc/README.md +0 -163
  226. package/node_modules/simple-yenc/dist/esm.js +0 -1
  227. package/node_modules/simple-yenc/dist/index.js +0 -1
  228. package/node_modules/simple-yenc/package.json +0 -50
  229. package/node_modules/simple-yenc/rollup.config.js +0 -27
  230. package/node_modules/simple-yenc/src/simple-yenc.js +0 -302
  231. package/node_modules/ws/LICENSE +0 -20
  232. package/node_modules/ws/README.md +0 -548
  233. package/node_modules/ws/browser.js +0 -8
  234. package/node_modules/ws/index.js +0 -13
  235. package/node_modules/ws/lib/buffer-util.js +0 -131
  236. package/node_modules/ws/lib/constants.js +0 -19
  237. package/node_modules/ws/lib/event-target.js +0 -292
  238. package/node_modules/ws/lib/extension.js +0 -203
  239. package/node_modules/ws/lib/limiter.js +0 -55
  240. package/node_modules/ws/lib/permessage-deflate.js +0 -528
  241. package/node_modules/ws/lib/receiver.js +0 -706
  242. package/node_modules/ws/lib/sender.js +0 -602
  243. package/node_modules/ws/lib/stream.js +0 -161
  244. package/node_modules/ws/lib/subprotocol.js +0 -62
  245. package/node_modules/ws/lib/validation.js +0 -152
  246. package/node_modules/ws/lib/websocket-server.js +0 -554
  247. package/node_modules/ws/lib/websocket.js +0 -1393
  248. package/node_modules/ws/package.json +0 -69
  249. package/node_modules/ws/wrapper.mjs +0 -8
  250. package/scripts/postinstall-link-sdk.js +0 -126
  251. package/skills/qqbot-media/SKILL.md +0 -60
  252. package/src/admin-resolver.ts +0 -181
  253. package/src/api.ts +0 -1319
  254. package/src/credential-backup.ts +0 -72
  255. package/src/deliver-debounce.ts +0 -229
  256. package/src/gateway.ts +0 -2304
  257. package/src/group-history.ts +0 -328
  258. package/src/image-server.ts +0 -675
  259. package/src/inbound-attachments.ts +0 -321
  260. package/src/known-users.ts +0 -353
  261. package/src/message-gating.ts +0 -190
  262. package/src/message-queue.ts +0 -354
  263. package/src/onboarding.ts +0 -274
  264. package/src/outbound-deliver.ts +0 -486
  265. package/src/outbound.ts +0 -1179
  266. package/src/ref-index-store.ts +0 -412
  267. package/src/reply-dispatcher.ts +0 -334
  268. package/src/session-store.ts +0 -303
  269. package/src/slash-commands.ts +0 -2297
  270. package/src/startup-greeting.ts +0 -120
  271. package/src/streaming.ts +0 -1077
  272. package/src/stt.ts +0 -86
  273. package/src/tools/channel.ts +0 -281
  274. package/src/typing-keepalive.ts +0 -59
  275. package/src/utils/audio-convert.ts +0 -859
  276. package/src/utils/chunked-upload.ts +0 -483
  277. package/src/utils/file-utils.ts +0 -193
  278. package/src/utils/image-size.ts +0 -266
  279. package/src/utils/media-send.ts +0 -636
  280. package/src/utils/media-tags.ts +0 -183
  281. package/src/utils/payload.ts +0 -265
  282. package/src/utils/text-parsing.ts +0 -85
  283. package/src/utils/upload-cache.ts +0 -128
@@ -0,0 +1,282 @@
1
+ /**
2
+ * 持久化 RefIndex 存储
3
+ *
4
+ * SDK 提供的 MemoryRefIndexStore 在进程重启后即丢失,
5
+ * 而 QQ 引用消息(REFIDX_xxx)入站事件只携带 key,必须本地缓存才能回查。
6
+ *
7
+ * 设计:
8
+ * - 内存 LRU(与 SDK 同款)保证 O(1) 读取
9
+ * - JSONL 追加写持久化,进程重启时按时间顺序回放重建 LRU
10
+ * - 写入触发 compact 阈值时重写文件(去重 + 截断到 maxEntries)
11
+ * - 文件路径: ~/.openclaw/qqbot/data/ref-index.jsonl
12
+ *
13
+ * 实现 SDK 的 RefIndexStore 接口,可直接通过 `quoteRef({ store })` 注入。
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import type { RefEntry, RefIndexStore } from '@tencent-connect/qqbot-nodejs';
19
+ import { getQQBotDataDir } from '../utils/platform.js';
20
+ import { createPluginLogger } from '../utils/plugin-logger.js';
21
+ const log = createPluginLogger({ prefix: '[ref-index]' });
22
+
23
+
24
+ // ── 常量 ──
25
+
26
+ const DEFAULT_MAX_ENTRIES = 50000;
27
+ const DEFAULT_FILENAME = 'ref-index.jsonl';
28
+ /** 当磁盘 line 数超过 maxEntries * COMPACT_RATIO 时触发 compact */
29
+ const COMPACT_RATIO = 2;
30
+ /** 内存 LRU 的最小容量保护值 */
31
+ const MIN_MAX_ENTRIES = 100;
32
+
33
+ // ── 磁盘行结构 ──
34
+
35
+ interface DiskLine {
36
+ /** RefIndex 键,例如 REFIDX_xxx 或 messageId */
37
+ k: string;
38
+ /** RefEntry 内容 */
39
+ v: RefEntry;
40
+ /** 写入时间戳(毫秒) */
41
+ t: number;
42
+ }
43
+
44
+ // ── 持久化 Store ──
45
+
46
+ export interface PersistedRefIndexStoreOptions {
47
+ /** 内存与磁盘的最大条目数。默认 2000。 */
48
+ maxEntries?: number;
49
+ /** 自定义存储文件路径。默认 ~/.openclaw/qqbot/data/ref-index.jsonl */
50
+ filePath?: string;
51
+ }
52
+
53
+ /**
54
+ * 持久化版本的 RefIndexStore
55
+ *
56
+ * - get:仅查内存(启动时从磁盘回放重建)
57
+ * - set:内存 + JSONL 追加写;磁盘行数过多时触发 compact
58
+ */
59
+ export class PersistedRefIndexStore implements RefIndexStore {
60
+ private readonly memory = new Map<string, RefEntry>();
61
+ private readonly maxEntries: number;
62
+ private readonly filePath: string;
63
+ /** 当前磁盘累计写入的行数(用于 compact 阈值判断) */
64
+ private diskLineCount = 0;
65
+ /** 是否已成功初始化(磁盘回放完成) */
66
+ private initialized = false;
67
+ /** 串行化写入,防止并发 append 撕裂行 */
68
+ private writeChain: Promise<void> = Promise.resolve();
69
+
70
+ constructor(options: PersistedRefIndexStoreOptions = {}) {
71
+ this.maxEntries = Math.max(options.maxEntries ?? DEFAULT_MAX_ENTRIES, MIN_MAX_ENTRIES);
72
+ this.filePath = options.filePath ?? path.join(getQQBotDataDir('data'), DEFAULT_FILENAME);
73
+ this.init();
74
+ }
75
+
76
+ /**
77
+ * 初始化:按时间顺序回放 JSONL 重建内存 LRU
78
+ */
79
+ private init(): void {
80
+ try {
81
+ if (!fs.existsSync(this.filePath)) {
82
+ this.initialized = true;
83
+ return;
84
+ }
85
+ const raw = fs.readFileSync(this.filePath, 'utf8');
86
+ const lines = raw.split('\n').filter((l) => l.length > 0);
87
+ this.diskLineCount = lines.length;
88
+
89
+ // 按时间排序回放(防止文件被外部修改后乱序)
90
+ const parsed: DiskLine[] = [];
91
+ for (const line of lines) {
92
+ try {
93
+ const obj = JSON.parse(line) as DiskLine;
94
+ if (obj?.k && obj?.v) {
95
+ parsed.push(obj);
96
+ }
97
+ } catch {
98
+ // 跳过损坏行
99
+ }
100
+ }
101
+ parsed.sort((a, b) => a.t - b.t);
102
+
103
+ for (const { k, v } of parsed) {
104
+ this.touchMemory(k, v);
105
+ }
106
+
107
+ // 启动时若磁盘行数已超阈值,立刻 compact 一次
108
+ if (this.diskLineCount > this.maxEntries * COMPACT_RATIO) {
109
+ this.compactSync();
110
+ }
111
+ } catch (err) {
112
+ log.error(
113
+ `init failed: ${err instanceof Error ? err.message : String(err)}`,
114
+ );
115
+ } finally {
116
+ this.initialized = true;
117
+ }
118
+ }
119
+
120
+ // ── RefIndexStore 接口 ──
121
+
122
+ get(key: string): RefEntry | undefined {
123
+ return this.memory.get(key);
124
+ }
125
+
126
+ set(key: string, entry: RefEntry): void {
127
+ this.touchMemory(key, entry);
128
+ // 追加写持久化(串行化)
129
+ this.writeChain = this.writeChain.then(() => this.appendToDisk(key, entry));
130
+ }
131
+
132
+ // ── 内部:内存 LRU 维护 ──
133
+
134
+ private touchMemory(key: string, entry: RefEntry): void {
135
+ // 已存在则先删除再插入,保证插入顺序 = LRU 顺序
136
+ if (this.memory.has(key)) {
137
+ this.memory.delete(key);
138
+ } else if (this.memory.size >= this.maxEntries) {
139
+ // 容量已满 → 淘汰最旧的(Map 的迭代顺序即插入顺序)
140
+ const oldest = this.memory.keys().next().value;
141
+ if (oldest !== undefined) {
142
+ this.memory.delete(oldest);
143
+ }
144
+ }
145
+ this.memory.set(key, entry);
146
+ }
147
+
148
+ // ── 内部:磁盘追加 + compact ──
149
+
150
+ private async appendToDisk(key: string, entry: RefEntry): Promise<void> {
151
+ const line: DiskLine = { k: key, v: entry, t: Date.now() };
152
+ const text = JSON.stringify(line) + '\n';
153
+ try {
154
+ await fs.promises.appendFile(this.filePath, text, 'utf8');
155
+ this.diskLineCount += 1;
156
+ if (this.diskLineCount > this.maxEntries * COMPACT_RATIO) {
157
+ await this.compact();
158
+ }
159
+ } catch (err) {
160
+ log.error(
161
+ `append failed: ${err instanceof Error ? err.message : String(err)}`,
162
+ );
163
+ }
164
+ }
165
+
166
+ /**
167
+ * 异步 compact:将内存 LRU 状态完整重写到磁盘,丢弃历史冗余。
168
+ */
169
+ private async compact(): Promise<void> {
170
+ const tmpPath = `${this.filePath}.tmp`;
171
+ try {
172
+ const now = Date.now();
173
+ // Map 迭代顺序 = 插入顺序(旧 → 新),保持 LRU 顺序
174
+ const lines: string[] = [];
175
+ for (const [k, v] of this.memory.entries()) {
176
+ lines.push(JSON.stringify({ k, v, t: now } satisfies DiskLine));
177
+ }
178
+ const content = lines.length > 0 ? lines.join('\n') + '\n' : '';
179
+ await fs.promises.writeFile(tmpPath, content, 'utf8');
180
+ await fs.promises.rename(tmpPath, this.filePath);
181
+ this.diskLineCount = lines.length;
182
+ log.info(
183
+ `compacted to ${lines.length} entries`,
184
+ );
185
+ } catch (err) {
186
+ log.error(
187
+ `compact failed: ${err instanceof Error ? err.message : String(err)}`,
188
+ );
189
+ // 清理可能残留的 tmp 文件
190
+ try {
191
+ await fs.promises.unlink(tmpPath);
192
+ } catch {
193
+ // ignore
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * 同步 compact(仅 init 阶段使用,避免回放后立刻保留巨大磁盘文件)
200
+ */
201
+ private compactSync(): void {
202
+ const tmpPath = `${this.filePath}.tmp`;
203
+ try {
204
+ const now = Date.now();
205
+ const lines: string[] = [];
206
+ for (const [k, v] of this.memory.entries()) {
207
+ lines.push(JSON.stringify({ k, v, t: now } satisfies DiskLine));
208
+ }
209
+ const content = lines.length > 0 ? lines.join('\n') + '\n' : '';
210
+ fs.writeFileSync(tmpPath, content, 'utf8');
211
+ fs.renameSync(tmpPath, this.filePath);
212
+ this.diskLineCount = lines.length;
213
+ } catch (err) {
214
+ log.error(
215
+ `compactSync failed: ${err instanceof Error ? err.message : String(err)}`,
216
+ );
217
+ }
218
+ }
219
+
220
+ // ── 诊断 ──
221
+
222
+ /** 当前内存中的条目数 */
223
+ get size(): number {
224
+ return this.memory.size;
225
+ }
226
+
227
+ /** 是否已完成初始化(磁盘回放) */
228
+ get isInitialized(): boolean {
229
+ return this.initialized;
230
+ }
231
+
232
+ /** 诊断快照 */
233
+ stats(): {
234
+ memoryEntries: number;
235
+ diskLines: number;
236
+ maxEntries: number;
237
+ filePath: string;
238
+ } {
239
+ return {
240
+ memoryEntries: this.memory.size,
241
+ diskLines: this.diskLineCount,
242
+ maxEntries: this.maxEntries,
243
+ filePath: this.filePath,
244
+ };
245
+ }
246
+
247
+ /**
248
+ * 强制将当前内存状态持久化到磁盘(进程退出前调用)
249
+ */
250
+ flush(): void {
251
+ this.compactSync();
252
+ }
253
+ }
254
+
255
+ // ── 默认单例(按 accountId 隔离) ──
256
+
257
+ const stores = new Map<string, PersistedRefIndexStore>();
258
+
259
+ /**
260
+ * 获取按 accountId 隔离的持久化 RefIndexStore 单例。
261
+ *
262
+ * 每个账户独立存储文件,避免多账户混用同一个 refIdx 命名空间。
263
+ */
264
+
265
+ export function getPersistedRefIndexStore(accountId: string): PersistedRefIndexStore {
266
+ let store = stores.get(accountId);
267
+ if (!store) {
268
+ const filePath = path.join(getQQBotDataDir('data', accountId), DEFAULT_FILENAME);
269
+ store = new PersistedRefIndexStore({ filePath });
270
+ stores.set(accountId, store);
271
+ }
272
+ return store;
273
+ }
274
+
275
+ /**
276
+ * 进程退出前 flush 所有 store
277
+ */
278
+ export function flushAllRefIndexStores(): void {
279
+ for (const store of stores.values()) {
280
+ store.flush();
281
+ }
282
+ }
@@ -8,11 +8,9 @@
8
8
  * 支持多 registry fallback:npmjs.org → npmmirror.com,解决国内网络问题。
9
9
  */
10
10
 
11
- import { createRequire } from "node:module";
12
11
  import https from "node:https";
13
- import { getPackageVersion } from "./utils/pkg-version.js";
14
-
15
- const require = createRequire(import.meta.url);
12
+ import { getPackageVersion } from "../utils/pkg-version.js";
13
+ import type { PluginLogger } from '../utils/plugin-logger.js';
16
14
 
17
15
  const PKG_NAME = "@tencent-connect/openclaw-qqbot";
18
16
  const ENCODED_PKG = encodeURIComponent(PKG_NAME);
@@ -22,23 +20,18 @@ const REGISTRIES = [
22
20
  `https://registry.npmmirror.com/${ENCODED_PKG}`,
23
21
  ];
24
22
 
25
- let CURRENT_VERSION = getPackageVersion(import.meta.url);
23
+ let CURRENT_VERSION = getPackageVersion();
26
24
 
27
25
  export interface UpdateInfo {
28
26
  current: string;
29
- /** 最佳升级目标(prerelease 用户优先 alpha,稳定版用户取 latest) */
30
27
  latest: string | null;
31
- /** 稳定版 dist-tag */
32
28
  stable: string | null;
33
- /** alpha dist-tag */
34
29
  alpha: string | null;
35
30
  hasUpdate: boolean;
36
31
  checkedAt: number;
37
32
  error?: string;
38
33
  }
39
34
 
40
- let _log: { info: (msg: string) => void; error: (msg: string) => void; debug?: (msg: string) => void } | undefined;
41
-
42
35
  function fetchJson(url: string, timeoutMs: number): Promise<any> {
43
36
  return new Promise((resolve, reject) => {
44
37
  const req = https.get(url, { timeout: timeoutMs, headers: { Accept: "application/json" } }, (res) => {
@@ -58,14 +51,14 @@ function fetchJson(url: string, timeoutMs: number): Promise<any> {
58
51
  });
59
52
  }
60
53
 
61
- async function fetchDistTags(): Promise<Record<string, string>> {
54
+ async function fetchDistTags(log?: PluginLogger): Promise<Record<string, string>> {
62
55
  for (const url of REGISTRIES) {
63
56
  try {
64
57
  const json = await fetchJson(url, 10_000);
65
58
  const tags = json["dist-tags"];
66
59
  if (tags && typeof tags === "object") return tags;
67
60
  } catch (e: any) {
68
- _log?.debug?.(`[qqbot:update-checker] ${url} failed: ${e.message}`);
61
+ log?.debug(`[update-checker] ${url} failed: ${e.message}`);
69
62
  }
70
63
  }
71
64
  throw new Error("all registries failed");
@@ -76,7 +69,6 @@ function buildUpdateInfo(tags: Record<string, string>): UpdateInfo {
76
69
  const stableTag = tags.latest || null;
77
70
  const alphaTag = tags.alpha || null;
78
71
 
79
- // 严格隔离:alpha 只跟 alpha 比,正式版只跟正式版比,不交叉
80
72
  const compareTarget = currentIsPrerelease ? alphaTag : stableTag;
81
73
 
82
74
  const hasUpdate = typeof compareTarget === "string"
@@ -93,37 +85,31 @@ function buildUpdateInfo(tags: Record<string, string>): UpdateInfo {
93
85
  };
94
86
  }
95
87
 
96
- /** gateway 启动时调用,保存 log 引用 */
97
- export function triggerUpdateCheck(log?: {
98
- info: (msg: string) => void;
99
- error: (msg: string) => void;
100
- debug?: (msg: string) => void;
101
- }): void {
102
- if (log) _log = log;
103
- // 预热:fire-and-forget
104
- getUpdateInfo().then((info) => {
88
+ /**
89
+ * Gateway 启动时调用,后台预热版本缓存。
90
+ * log 通过闭包传递,避免模块级可变状态。
91
+ */
92
+ export function triggerUpdateCheck(log: PluginLogger): void {
93
+ getUpdateInfo(log).then((info) => {
105
94
  if (info.hasUpdate) {
106
- _log?.info?.(`[qqbot:update-checker] new version available: ${info.latest} (current: ${CURRENT_VERSION})`);
95
+ log.info(`[update-checker] new version available: ${info.latest} (current: ${CURRENT_VERSION})`);
107
96
  }
108
97
  }).catch(() => {});
109
98
  }
110
99
 
111
100
  /** 每次实时查询 npm registry */
112
- export async function getUpdateInfo(): Promise<UpdateInfo> {
101
+ export async function getUpdateInfo(log?: PluginLogger): Promise<UpdateInfo> {
113
102
  try {
114
- const tags = await fetchDistTags();
103
+ const tags = await fetchDistTags(log);
115
104
  return buildUpdateInfo(tags);
116
105
  } catch (err: any) {
117
- _log?.debug?.(`[qqbot:update-checker] check failed: ${err.message}`);
106
+ log?.debug(`[update-checker] check failed: ${err.message}`);
118
107
  return { current: CURRENT_VERSION, latest: null, stable: null, alpha: null, hasUpdate: false, checkedAt: Date.now(), error: err.message };
119
108
  }
120
109
  }
121
110
 
122
111
  /**
123
112
  * 检查指定版本是否存在于 npm registry
124
- * 用于 /bot-upgrade --version 的前置校验
125
- * @param version 要检查的版本号
126
- * @param pkgName 可选的包名(如 "@ryantest/openclaw-qqbot"),默认使用内置包名
127
113
  */
128
114
  export async function checkVersionExists(version: string, pkgName?: string): Promise<boolean> {
129
115
  const registries = pkgName ? buildRegistries(pkgName) : REGISTRIES;
@@ -139,7 +125,6 @@ export async function checkVersionExists(version: string, pkgName?: string): Pro
139
125
  return false;
140
126
  }
141
127
 
142
- /** 根据自定义包名构建 registry URL 列表 */
143
128
  function buildRegistries(pkgName: string): string[] {
144
129
  const encoded = encodeURIComponent(pkgName);
145
130
  return [
@@ -156,16 +141,13 @@ function compareVersions(a: string, b: string): number {
156
141
  };
157
142
  const pa = parse(a);
158
143
  const pb = parse(b);
159
- // 先比主版本号
160
144
  for (let i = 0; i < 3; i++) {
161
145
  const diff = (pa.parts[i] || 0) - (pb.parts[i] || 0);
162
146
  if (diff !== 0) return diff;
163
147
  }
164
- // 主版本号相同:正式版 > prerelease
165
148
  if (!pa.pre && pb.pre) return 1;
166
149
  if (pa.pre && !pb.pre) return -1;
167
150
  if (!pa.pre && !pb.pre) return 0;
168
- // 都是 prerelease:按段逐一比较(alpha.1 vs alpha.2)
169
151
  const aParts = pa.pre!.split(".");
170
152
  const bParts = pb.pre!.split(".");
171
153
  for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
@@ -173,11 +155,9 @@ function compareVersions(a: string, b: string): number {
173
155
  const bP = bParts[i] ?? "";
174
156
  const aNum = Number(aP);
175
157
  const bNum = Number(bP);
176
- // 都是数字则按数字比较
177
158
  if (!isNaN(aNum) && !isNaN(bNum)) {
178
159
  if (aNum !== bNum) return aNum - bNum;
179
160
  } else {
180
- // 字符串比较
181
161
  if (aP < bP) return -1;
182
162
  if (aP > bP) return 1;
183
163
  }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * QQBotGateway 事件处理
3
+ *
4
+ * 处理 SDK 的 message / interaction 事件:
5
+ * - message: 中间件处理完毕后,将消息转发到 OpenClaw AI
6
+ * - interaction: 配置更新 / 审批按钮
7
+ */
8
+
9
+ import type { MiddlewareContext, QQBotInboundMessage, InteractionEvent } from '@tencent-connect/qqbot-nodejs';
10
+ import type { PluginRuntime } from 'openclaw/plugin-sdk';
11
+ import type { ResolvedQQBotAccount } from '../types.js';
12
+ import type { PluginLogger } from '../utils/plugin-logger.js';
13
+ import { dispatchToOpenClaw } from '../dispatch/index.js';
14
+ import { runWithRequestContext } from '../request-context.js';
15
+ import { getApprovalHandler } from '../features/approval-handler.js';
16
+ import { recordKnownUser } from '../features/proactive.js';
17
+ import { cacheMsgId } from '../features/msgid-cache.js';
18
+ import { getAdapters } from '../adapter/resolve.js';
19
+ import { resolveGroupConfigFromAccount, resolveGroupPolicy, resolveMentionPatterns } from '../config.js';
20
+ import { getPackageVersion } from '../utils/pkg-version.js';
21
+ import { getOpenClawVersion } from '../bot-instance.js';
22
+
23
+ export async function handleMessage(
24
+ ctx: MiddlewareContext,
25
+ msg: QQBotInboundMessage,
26
+ account: ResolvedQQBotAccount,
27
+ runtime: PluginRuntime,
28
+ log: PluginLogger,
29
+ ): Promise<void> {
30
+ const hlog = log.child('handle');
31
+ const scope = msg.replyTarget.scope;
32
+ const targetId = scope === 'group'
33
+ ? `qqbot:group:${msg.replyTarget.targetId}`
34
+ : `qqbot:c2c:${msg.replyTarget.targetId}`;
35
+
36
+ const mergedCount = (ctx.state.mergedMessages as unknown[] | undefined)?.length;
37
+ if (mergedCount) {
38
+ hlog.info(`merged batch count=${mergedCount} msgId=${msg.messageId}`);
39
+ } else {
40
+ hlog.debug(`enter msgId=${msg.messageId} scope=${scope} contentLen=${(msg.content ?? '').length}`);
41
+ }
42
+
43
+ try {
44
+ cacheMsgId(scope, msg.replyTarget.targetId, msg.messageId);
45
+
46
+ recordKnownUser({
47
+ type: scope === 'group' ? 'group' : 'c2c',
48
+ openid: scope === 'group' ? msg.replyTarget.targetId : msg.senderId,
49
+ accountId: account.accountId,
50
+ nickname: msg.senderName,
51
+ lastInteractionAt: Date.now(),
52
+ });
53
+
54
+ await runWithRequestContext(
55
+ {
56
+ accountId: account.accountId,
57
+ messageId: msg.messageId,
58
+ openId: msg.senderId,
59
+ target: targetId,
60
+ },
61
+ () => dispatchToOpenClaw(ctx, msg, account, runtime, log),
62
+ );
63
+ } catch (err) {
64
+ hlog.error(`dispatch error: ${err}`);
65
+ }
66
+ hlog.debug(`done msgId=${msg.messageId}`);
67
+ }
68
+
69
+ const INTERACTION_QUERY = 2001;
70
+ const INTERACTION_UPDATE = 2002;
71
+
72
+ export async function handleInteraction(
73
+ event: InteractionEvent,
74
+ account: ResolvedQQBotAccount,
75
+ runtime: PluginRuntime,
76
+ log: PluginLogger,
77
+ acknowledgeInteraction: (id: string, code?: number, data?: Record<string, unknown>) => Promise<void>,
78
+ ): Promise<void> {
79
+ if (event.data?.type === INTERACTION_QUERY) {
80
+ await handleConfigQuery(event, account, runtime, log, acknowledgeInteraction);
81
+ return;
82
+ }
83
+ if (event.data?.type === INTERACTION_UPDATE) {
84
+ await handleConfigUpdate(event, account, runtime, log);
85
+ try {
86
+ const adapters = getAdapters(runtime);
87
+ const cfg = adapters.getConfig?.() ?? {};
88
+ const groupOpenid = (event as any).group_openid ?? '';
89
+ const updatedCfg = groupOpenid ? resolveGroupConfigFromAccount(account, groupOpenid) : null;
90
+ const requireMention = updatedCfg?.requireMention ?? true;
91
+ const clawCfg = buildClawCfg(requireMention, [], resolveGroupPolicy(cfg, account.accountId));
92
+ await acknowledgeInteraction(event.id, 0, { claw_cfg: clawCfg });
93
+ } catch {
94
+ try { await acknowledgeInteraction(event.id); } catch { /* ignore */ }
95
+ }
96
+ return;
97
+ }
98
+
99
+ await handleApproval(event, account, log, acknowledgeInteraction);
100
+ }
101
+
102
+ // ── Interaction 子处理 ──
103
+
104
+ async function handleConfigQuery(
105
+ event: InteractionEvent,
106
+ account: ResolvedQQBotAccount,
107
+ runtime: PluginRuntime,
108
+ log: PluginLogger,
109
+ ack: (id: string, code?: number, data?: Record<string, unknown>) => Promise<void>,
110
+ ): Promise<void> {
111
+ const groupOpenid = (event as any).group_openid ?? '';
112
+ try {
113
+ const adapters = getAdapters(runtime);
114
+ const cfg = adapters.getConfig?.() ?? {};
115
+ const groupCfg = groupOpenid ? resolveGroupConfigFromAccount(account, groupOpenid) : null;
116
+ const requireMention = groupCfg?.requireMention ?? true;
117
+ const agentId = groupOpenid
118
+ ? adapters.resolveAgentRoute?.({ cfg, channel: 'qqbot', accountId: account.accountId, peer: { kind: 'group', id: groupOpenid } })?.agentId
119
+ : undefined;
120
+ const mentionPatterns = resolveMentionPatterns(cfg, agentId);
121
+ const clawCfg = buildClawCfg(requireMention, mentionPatterns, resolveGroupPolicy(cfg, account.accountId));
122
+ log.info(`interaction query: group=${groupOpenid} requireMention=${requireMention}`);
123
+ await ack(event.id, 0, { claw_cfg: clawCfg });
124
+ } catch (err) {
125
+ log.warn(`interaction query failed: ${(err as Error)?.message ?? err}, ack without data`);
126
+ try { await ack(event.id); } catch { /* ignore */ }
127
+ }
128
+ }
129
+
130
+ async function handleConfigUpdate(
131
+ event: InteractionEvent,
132
+ account: ResolvedQQBotAccount,
133
+ runtime: PluginRuntime,
134
+ log: PluginLogger,
135
+ ): Promise<void> {
136
+ const resolved = (event.data as any)?.resolved;
137
+ const update = resolved?.claw_cfg;
138
+ const groupOpenid = (event as any).group_openid ?? '';
139
+
140
+ if (update?.require_mention !== undefined && groupOpenid) {
141
+ try {
142
+ await setGroupRequireMention(runtime, account.accountId, groupOpenid, update.require_mention === 'mention');
143
+ log.info(`interaction: group=${groupOpenid} requireMention=${update.require_mention}`);
144
+ } catch (err) {
145
+ log.error(`interaction update failed: ${err}`);
146
+ }
147
+ }
148
+ }
149
+
150
+ async function handleApproval(
151
+ event: InteractionEvent,
152
+ account: ResolvedQQBotAccount,
153
+ log: PluginLogger,
154
+ ack: (id: string) => Promise<void>,
155
+ ): Promise<void> {
156
+ try { await ack(event.id); } catch { /* ignore */ }
157
+
158
+ const buttonData = event.data?.resolved?.button_data;
159
+ if (!buttonData?.startsWith('approve:')) return;
160
+
161
+ // 身份授权校验:操作者需在 allowFrom 白名单中
162
+ const operatorId = resolveOperatorId(event);
163
+ if (!isApprovalAuthorized(account, operatorId)) {
164
+ log.warn(`[approval] unauthorized operator=${operatorId ?? 'unknown'} account=${account.accountId}`);
165
+ return;
166
+ }
167
+
168
+ const parts = buttonData.split(':');
169
+ if (parts.length < 3) return;
170
+
171
+ const handler = getApprovalHandler(account.accountId);
172
+ if (!handler) return;
173
+
174
+ const approvalId = parts[1];
175
+ const decision = parts[2] as 'allow-once' | 'allow-always' | 'deny';
176
+ try { await handler.resolveApproval(approvalId, decision); } catch (err) {
177
+ log.error(`interaction approve error: ${err instanceof Error ? err.message : String(err)}`);
178
+ }
179
+ }
180
+
181
+ const CHANNEL_VER = getPackageVersion();
182
+
183
+ // ── 审批授权校验 ──
184
+
185
+ /**
186
+ * 从交互事件中提取操作者身份标识。
187
+ * QQ Bot 按钮回调事件中,操作者 openid 通常在 `user_openid` 或 `data.resolved.user_id` 字段。
188
+ */
189
+ function resolveOperatorId(event: InteractionEvent): string | undefined {
190
+ const evt = event as any;
191
+ return evt.user_openid
192
+ ?? evt.data?.resolved?.user_id
193
+ ?? evt.data?.resolved?.user_openid
194
+ ?? evt.openid;
195
+ }
196
+
197
+ /**
198
+ * 校验操作者是否有权限执行审批操作。
199
+ * 规则:allowFrom 为空或含 "*" → 开放;否则操作者必须在 allowFrom 中。
200
+ */
201
+ function isApprovalAuthorized(account: ResolvedQQBotAccount, operatorId?: string): boolean {
202
+ if (!operatorId) return false;
203
+ const allowFrom = account.config?.allowFrom ?? [];
204
+ if (!allowFrom.length || allowFrom.includes('*')) return true;
205
+ return allowFrom.includes(operatorId);
206
+ }
207
+
208
+ function buildClawCfg(
209
+ requireMention: boolean,
210
+ mentionPatterns: string[],
211
+ groupPolicy: string,
212
+ ): Record<string, unknown> {
213
+ return {
214
+ channel_type: 'qqbot',
215
+ channel_ver: CHANNEL_VER,
216
+ claw_type: 'openclaw',
217
+ claw_ver: getOpenClawVersion(),
218
+ require_mention: requireMention ? 'mention' : 'always',
219
+ group_policy: groupPolicy,
220
+ mention_patterns: mentionPatterns.join(','),
221
+ online_state: 'online',
222
+ };
223
+ }
224
+
225
+ // ── Config 写入 ──
226
+
227
+ function setGroupRequireMention(
228
+ runtime: PluginRuntime,
229
+ accountId: string,
230
+ groupOpenid: string,
231
+ requireMention: boolean,
232
+ ): Promise<void> {
233
+ const adapters = getAdapters(runtime);
234
+ return adapters.persistConfig?.((cfg: any) => {
235
+ const qqbot = (cfg.channels ?? {})?.qqbot ?? {};
236
+ const groups = accountId !== 'default' && qqbot.accounts?.[accountId]
237
+ ? (qqbot.accounts[accountId].groups = { ...qqbot.accounts[accountId].groups })
238
+ : (qqbot.groups = { ...qqbot.groups });
239
+ groups[groupOpenid] = { ...groups[groupOpenid], requireMention };
240
+ }) ?? Promise.resolve();
241
+ }
@@ -0,0 +1,3 @@
1
+ export { QQBotGateway } from './qqbot-gateway.js';
2
+ export type { GatewayCallbacks } from './qqbot-gateway.js';
3
+ export { startAccountWithCredentialRecovery, logoutAndClearCredentials } from './lifecycle.js';