@tencent-connect/openclaw-qqbot 1.7.2 → 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 (291) hide show
  1. package/README.md +25 -8
  2. package/README.zh.md +24 -8
  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 -19
  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 +42 -11
  13. package/scripts/upgrade-via-source.sh +4 -4
  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 +46 -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 +2 -0
  85. package/src/types-augment.d.ts +54 -0
  86. package/src/types.ts +51 -9
  87. package/src/utils/mention.ts +52 -0
  88. package/src/utils/pkg-version.ts +84 -47
  89. package/src/utils/platform.ts +31 -7
  90. package/src/utils/plugin-logger.ts +104 -0
  91. package/src/utils/ssrf-guard.ts +32 -2
  92. package/src/utils/stt.ts +137 -0
  93. package/src/utils/voice-text.ts +61 -0
  94. package/tsconfig.json +2 -1
  95. package/tsup.config.ts +57 -0
  96. package/dist/index.d.ts +0 -18
  97. package/dist/index.js +0 -27
  98. package/dist/src/admin-resolver.d.ts +0 -33
  99. package/dist/src/admin-resolver.js +0 -157
  100. package/dist/src/api.d.ts +0 -309
  101. package/dist/src/api.js +0 -923
  102. package/dist/src/approval-handler.d.ts +0 -47
  103. package/dist/src/approval-handler.js +0 -372
  104. package/dist/src/channel.d.ts +0 -29
  105. package/dist/src/channel.js +0 -528
  106. package/dist/src/config.d.ts +0 -60
  107. package/dist/src/config.js +0 -288
  108. package/dist/src/credential-backup.d.ts +0 -31
  109. package/dist/src/credential-backup.js +0 -66
  110. package/dist/src/deliver-debounce.d.ts +0 -74
  111. package/dist/src/deliver-debounce.js +0 -174
  112. package/dist/src/gateway.d.ts +0 -18
  113. package/dist/src/gateway.js +0 -2025
  114. package/dist/src/group-history.d.ts +0 -136
  115. package/dist/src/group-history.js +0 -226
  116. package/dist/src/image-server.d.ts +0 -87
  117. package/dist/src/image-server.js +0 -570
  118. package/dist/src/inbound-attachments.d.ts +0 -60
  119. package/dist/src/inbound-attachments.js +0 -248
  120. package/dist/src/known-users.d.ts +0 -100
  121. package/dist/src/known-users.js +0 -263
  122. package/dist/src/message-gating.d.ts +0 -53
  123. package/dist/src/message-gating.js +0 -107
  124. package/dist/src/message-queue.d.ts +0 -91
  125. package/dist/src/message-queue.js +0 -257
  126. package/dist/src/onboarding.d.ts +0 -10
  127. package/dist/src/onboarding.js +0 -203
  128. package/dist/src/outbound-deliver.d.ts +0 -48
  129. package/dist/src/outbound-deliver.js +0 -392
  130. package/dist/src/outbound.d.ts +0 -220
  131. package/dist/src/outbound.js +0 -975
  132. package/dist/src/proactive.d.ts +0 -170
  133. package/dist/src/proactive.js +0 -399
  134. package/dist/src/ref-index-store.d.ts +0 -101
  135. package/dist/src/ref-index-store.js +0 -298
  136. package/dist/src/reply-dispatcher.d.ts +0 -35
  137. package/dist/src/reply-dispatcher.js +0 -311
  138. package/dist/src/request-context.d.ts +0 -25
  139. package/dist/src/request-context.js +0 -37
  140. package/dist/src/runtime.d.ts +0 -3
  141. package/dist/src/runtime.js +0 -13
  142. package/dist/src/session-store.d.ts +0 -52
  143. package/dist/src/session-store.js +0 -254
  144. package/dist/src/slash-commands.d.ts +0 -82
  145. package/dist/src/slash-commands.js +0 -2233
  146. package/dist/src/startup-greeting.d.ts +0 -30
  147. package/dist/src/startup-greeting.js +0 -97
  148. package/dist/src/streaming.d.ts +0 -247
  149. package/dist/src/streaming.js +0 -899
  150. package/dist/src/stt.d.ts +0 -21
  151. package/dist/src/stt.js +0 -70
  152. package/dist/src/tools/channel.d.ts +0 -16
  153. package/dist/src/tools/channel.js +0 -231
  154. package/dist/src/tools/remind.d.ts +0 -2
  155. package/dist/src/tools/remind.js +0 -255
  156. package/dist/src/transport/index.d.ts +0 -10
  157. package/dist/src/transport/index.js +0 -9
  158. package/dist/src/transport/webhook-transport.d.ts +0 -67
  159. package/dist/src/transport/webhook-transport.js +0 -245
  160. package/dist/src/transport/webhook-verify.d.ts +0 -48
  161. package/dist/src/transport/webhook-verify.js +0 -98
  162. package/dist/src/types.d.ts +0 -462
  163. package/dist/src/types.js +0 -22
  164. package/dist/src/typing-keepalive.d.ts +0 -27
  165. package/dist/src/typing-keepalive.js +0 -64
  166. package/dist/src/update-checker.d.ts +0 -36
  167. package/dist/src/update-checker.js +0 -171
  168. package/dist/src/utils/audio-convert.d.ts +0 -98
  169. package/dist/src/utils/audio-convert.js +0 -783
  170. package/dist/src/utils/chunked-upload.d.ts +0 -68
  171. package/dist/src/utils/chunked-upload.js +0 -341
  172. package/dist/src/utils/file-utils.d.ts +0 -61
  173. package/dist/src/utils/file-utils.js +0 -172
  174. package/dist/src/utils/image-size.d.ts +0 -51
  175. package/dist/src/utils/image-size.js +0 -234
  176. package/dist/src/utils/media-send.d.ts +0 -158
  177. package/dist/src/utils/media-send.js +0 -502
  178. package/dist/src/utils/media-tags.d.ts +0 -14
  179. package/dist/src/utils/media-tags.js +0 -165
  180. package/dist/src/utils/payload.d.ts +0 -112
  181. package/dist/src/utils/payload.js +0 -186
  182. package/dist/src/utils/pkg-version.d.ts +0 -5
  183. package/dist/src/utils/pkg-version.js +0 -61
  184. package/dist/src/utils/platform.d.ts +0 -137
  185. package/dist/src/utils/platform.js +0 -390
  186. package/dist/src/utils/ssrf-guard.d.ts +0 -25
  187. package/dist/src/utils/ssrf-guard.js +0 -91
  188. package/dist/src/utils/text-parsing.d.ts +0 -31
  189. package/dist/src/utils/text-parsing.js +0 -74
  190. package/dist/src/utils/upload-cache.d.ts +0 -34
  191. package/dist/src/utils/upload-cache.js +0 -93
  192. package/node_modules/@eshaz/web-worker/LICENSE +0 -201
  193. package/node_modules/@eshaz/web-worker/README.md +0 -134
  194. package/node_modules/@eshaz/web-worker/browser.js +0 -17
  195. package/node_modules/@eshaz/web-worker/cjs/browser.js +0 -16
  196. package/node_modules/@eshaz/web-worker/cjs/node.js +0 -219
  197. package/node_modules/@eshaz/web-worker/index.d.ts +0 -4
  198. package/node_modules/@eshaz/web-worker/node.js +0 -223
  199. package/node_modules/@eshaz/web-worker/package.json +0 -54
  200. package/node_modules/@wasm-audio-decoders/common/index.js +0 -5
  201. package/node_modules/@wasm-audio-decoders/common/package.json +0 -36
  202. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +0 -231
  203. package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +0 -129
  204. package/node_modules/@wasm-audio-decoders/common/src/puff/README +0 -67
  205. package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +0 -31
  206. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +0 -863
  207. package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +0 -35
  208. package/node_modules/@wasm-audio-decoders/common/src/utilities.js +0 -3
  209. package/node_modules/@wasm-audio-decoders/common/types.d.ts +0 -7
  210. package/node_modules/mpg123-decoder/README.md +0 -265
  211. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +0 -185
  212. package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +0 -1
  213. package/node_modules/mpg123-decoder/index.js +0 -8
  214. package/node_modules/mpg123-decoder/package.json +0 -58
  215. package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +0 -464
  216. package/node_modules/mpg123-decoder/src/MPEGDecoder.js +0 -200
  217. package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +0 -21
  218. package/node_modules/mpg123-decoder/types.d.ts +0 -30
  219. package/node_modules/silk-wasm/LICENSE +0 -21
  220. package/node_modules/silk-wasm/README.md +0 -85
  221. package/node_modules/silk-wasm/lib/index.cjs +0 -16
  222. package/node_modules/silk-wasm/lib/index.d.ts +0 -70
  223. package/node_modules/silk-wasm/lib/index.mjs +0 -16
  224. package/node_modules/silk-wasm/lib/silk.wasm +0 -0
  225. package/node_modules/silk-wasm/lib/utils.d.ts +0 -4
  226. package/node_modules/silk-wasm/package.json +0 -39
  227. package/node_modules/simple-yenc/.github/FUNDING.yml +0 -1
  228. package/node_modules/simple-yenc/.prettierignore +0 -1
  229. package/node_modules/simple-yenc/LICENSE +0 -7
  230. package/node_modules/simple-yenc/README.md +0 -163
  231. package/node_modules/simple-yenc/dist/esm.js +0 -1
  232. package/node_modules/simple-yenc/dist/index.js +0 -1
  233. package/node_modules/simple-yenc/package.json +0 -50
  234. package/node_modules/simple-yenc/rollup.config.js +0 -27
  235. package/node_modules/simple-yenc/src/simple-yenc.js +0 -302
  236. package/node_modules/ws/LICENSE +0 -20
  237. package/node_modules/ws/README.md +0 -548
  238. package/node_modules/ws/browser.js +0 -8
  239. package/node_modules/ws/index.js +0 -13
  240. package/node_modules/ws/lib/buffer-util.js +0 -131
  241. package/node_modules/ws/lib/constants.js +0 -19
  242. package/node_modules/ws/lib/event-target.js +0 -292
  243. package/node_modules/ws/lib/extension.js +0 -203
  244. package/node_modules/ws/lib/limiter.js +0 -55
  245. package/node_modules/ws/lib/permessage-deflate.js +0 -528
  246. package/node_modules/ws/lib/receiver.js +0 -706
  247. package/node_modules/ws/lib/sender.js +0 -602
  248. package/node_modules/ws/lib/stream.js +0 -161
  249. package/node_modules/ws/lib/subprotocol.js +0 -62
  250. package/node_modules/ws/lib/validation.js +0 -152
  251. package/node_modules/ws/lib/websocket-server.js +0 -554
  252. package/node_modules/ws/lib/websocket.js +0 -1393
  253. package/node_modules/ws/package.json +0 -69
  254. package/node_modules/ws/wrapper.mjs +0 -8
  255. package/scripts/postinstall-link-sdk.js +0 -170
  256. package/skills/qqbot-media/SKILL.md +0 -60
  257. package/src/admin-resolver.ts +0 -181
  258. package/src/api.ts +0 -1333
  259. package/src/credential-backup.ts +0 -72
  260. package/src/deliver-debounce.ts +0 -229
  261. package/src/gateway.ts +0 -2272
  262. package/src/group-history.ts +0 -328
  263. package/src/image-server.ts +0 -675
  264. package/src/inbound-attachments.ts +0 -321
  265. package/src/known-users.ts +0 -353
  266. package/src/message-gating.ts +0 -190
  267. package/src/message-queue.ts +0 -354
  268. package/src/onboarding.ts +0 -282
  269. package/src/outbound-deliver.ts +0 -486
  270. package/src/outbound.ts +0 -1179
  271. package/src/ref-index-store.ts +0 -412
  272. package/src/reply-dispatcher.ts +0 -334
  273. package/src/session-store.ts +0 -303
  274. package/src/slash-commands.ts +0 -2431
  275. package/src/startup-greeting.ts +0 -120
  276. package/src/streaming.ts +0 -1077
  277. package/src/stt.ts +0 -86
  278. package/src/tools/channel.ts +0 -275
  279. package/src/transport/index.ts +0 -11
  280. package/src/transport/webhook-transport.ts +0 -332
  281. package/src/transport/webhook-verify.ts +0 -119
  282. package/src/typing-keepalive.ts +0 -59
  283. package/src/utils/audio-convert.ts +0 -887
  284. package/src/utils/chunked-upload.ts +0 -483
  285. package/src/utils/file-utils.ts +0 -193
  286. package/src/utils/image-size.ts +0 -266
  287. package/src/utils/media-send.ts +0 -636
  288. package/src/utils/media-tags.ts +0 -183
  289. package/src/utils/payload.ts +0 -265
  290. package/src/utils/text-parsing.ts +0 -85
  291. 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';