@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,222 @@
1
+ /**
2
+ * 出站消息服务
3
+ *
4
+ * 负责将 AI 回复通过 QQBotGateway 发送到 QQ。
5
+ * 超时保护由 QQBotGateway 内部统一处理,本层做 target 解析 + 被动回复限额管控。
6
+ */
7
+ import * as path from 'node:path';
8
+ import { MediaFileType } from '@tencent-connect/qqbot-nodejs';
9
+ import type { QQBotGateway } from '../gateway/index.js';
10
+ import type { ResolvedQQBotAccount } from '../types.js';
11
+ import { parseTarget } from './target.js';
12
+ import { ReplyLimiter } from './reply-limiter.js';
13
+
14
+ // ── Gateway + Limiter 注册表(生命周期由 channel.ts 管理)──
15
+
16
+ const gateways = new Map<string, QQBotGateway>();
17
+ const limiters = new Map<string, ReplyLimiter>();
18
+
19
+ function getLimiter(accountId: string): ReplyLimiter {
20
+ let l = limiters.get(accountId);
21
+ if (!l) {
22
+ l = new ReplyLimiter();
23
+ limiters.set(accountId, l);
24
+ }
25
+ return l;
26
+ }
27
+
28
+ /**
29
+ * 解析 replyToId:超出被动回复限额时自动降级为主动消息(不传 msgId)。
30
+ * @returns 实际使用的 msgId(超限时为 undefined)
31
+ */
32
+ function resolveMsgId(replyToId: string | undefined, accountId: string): string | undefined {
33
+ if (!replyToId) return undefined;
34
+ const limiter = getLimiter(accountId);
35
+ const result = limiter.checkLimit(replyToId);
36
+ if (!result.allowed) return undefined;
37
+ limiter.record(replyToId);
38
+ return replyToId;
39
+ }
40
+
41
+ export function registerGateway(accountId: string, gw: QQBotGateway): void {
42
+ gateways.set(accountId, gw);
43
+ }
44
+
45
+ export function unregisterGateway(accountId: string): void {
46
+ gateways.delete(accountId);
47
+ limiters.delete(accountId);
48
+ }
49
+
50
+ export function getGateway(accountId: string): QQBotGateway | undefined {
51
+ return gateways.get(accountId);
52
+ }
53
+
54
+ // ── 媒体类型映射 ──
55
+
56
+ export type MediaKind = 'image' | 'voice' | 'video' | 'file';
57
+
58
+ const MEDIA_KIND_TO_FILE_TYPE: Record<MediaKind, MediaFileType> = {
59
+ image: MediaFileType.IMAGE,
60
+ voice: MediaFileType.VOICE,
61
+ video: MediaFileType.VIDEO,
62
+ file: MediaFileType.FILE,
63
+ };
64
+
65
+ export interface SendResult {
66
+ messageId?: string;
67
+ error?: string;
68
+ errorCode?: string;
69
+ qqBizCode?: number;
70
+ }
71
+
72
+ // ── 公开 API(channel.ts / deliver-pipeline.ts 调用)──
73
+
74
+ export async function sendText(params: {
75
+ to: string;
76
+ text: string;
77
+ accountId?: string;
78
+ replyToId?: string;
79
+ account: ResolvedQQBotAccount;
80
+ }): Promise<SendResult> {
81
+ const accountId = params.account.accountId;
82
+ const gw = gateways.get(accountId);
83
+ if (!gw) return { error: `Bot "${accountId}" not running` };
84
+ try {
85
+ const target = parseTarget(params.to);
86
+ const msgId = resolveMsgId(params.replyToId, accountId);
87
+ const result = await gw.sendText(target, params.text, { msgId });
88
+ return { messageId: result.id };
89
+ } catch (err: unknown) { return formatError(err); }
90
+ }
91
+
92
+ export async function sendMedia(params: {
93
+ to: string;
94
+ text?: string;
95
+ mediaUrl: string;
96
+ mediaKind?: MediaKind;
97
+ accountId?: string;
98
+ replyToId?: string;
99
+ account: ResolvedQQBotAccount;
100
+ }): Promise<SendResult> {
101
+ const accountId = params.account.accountId;
102
+ const gw = gateways.get(accountId);
103
+ if (!gw) return { error: `Bot "${accountId}" not running` };
104
+ try {
105
+ const target = parseTarget(params.to);
106
+ const kind = params.mediaKind ?? 'image';
107
+ const msgId = resolveMsgId(params.replyToId, accountId);
108
+ if (kind === 'voice') {
109
+ const source = resolveVoiceSource(params.mediaUrl);
110
+ const result = await gw.sendVoice(target, source, { text: params.text, msgId });
111
+ return { messageId: result.id };
112
+ }
113
+ if (kind === 'video') {
114
+ const result = await gw.sendVideo(target, params.mediaUrl, { text: params.text, msgId });
115
+ return { messageId: result.id };
116
+ }
117
+ if (kind === 'file') {
118
+ const result = await gw.sendFile(target, params.mediaUrl, { text: params.text, msgId });
119
+ return { messageId: result.id };
120
+ }
121
+ const fileType = MEDIA_KIND_TO_FILE_TYPE[kind];
122
+ const result = await gw.sendMedia(target, params.mediaUrl, { text: params.text, msgId, fileType });
123
+ return { messageId: result.id };
124
+ } catch (err: unknown) { return formatError(err); }
125
+ }
126
+
127
+ export async function sendVoice(params: {
128
+ to: string;
129
+ source: { url?: string; base64?: string };
130
+ accountId?: string;
131
+ replyToId?: string;
132
+ account: ResolvedQQBotAccount;
133
+ }): Promise<SendResult> {
134
+ const accountId = params.account.accountId;
135
+ const gw = gateways.get(accountId);
136
+ if (!gw) return { error: `Bot "${accountId}" not running` };
137
+ try {
138
+ const target = parseTarget(params.to);
139
+ const msgId = resolveMsgId(params.replyToId, accountId);
140
+ const result = await gw.sendVoice(target, params.source, { msgId });
141
+ return { messageId: result.id };
142
+ } catch (err: unknown) { return formatError(err); }
143
+ }
144
+
145
+ export async function sendVideo(params: {
146
+ to: string;
147
+ videoUrl: string;
148
+ accountId?: string;
149
+ replyToId?: string;
150
+ account: ResolvedQQBotAccount;
151
+ }): Promise<SendResult> {
152
+ const accountId = params.account.accountId;
153
+ const gw = gateways.get(accountId);
154
+ if (!gw) return { error: `Bot "${accountId}" not running` };
155
+ try {
156
+ const target = parseTarget(params.to);
157
+ const msgId = resolveMsgId(params.replyToId, accountId);
158
+ const result = await gw.sendVideo(target, params.videoUrl, { msgId });
159
+ return { messageId: result.id };
160
+ } catch (err: unknown) { return formatError(err); }
161
+ }
162
+
163
+ // ── OutboundService(deliver-pipeline 专用)──
164
+
165
+ export class OutboundService {
166
+ constructor(private readonly gw: QQBotGateway, private readonly accountId: string) {}
167
+
168
+ async sendText(to: string, text: string, msgId?: string): Promise<SendResult> {
169
+ try {
170
+ const target = parseTarget(to);
171
+ const resolvedMsgId = resolveMsgId(msgId, this.accountId);
172
+ const result = await this.gw.sendText(target, text, { msgId: resolvedMsgId });
173
+ return { messageId: result.id };
174
+ } catch (err: unknown) { return formatError(err); }
175
+ }
176
+
177
+ async sendMedia(to: string, source: string, opts?: { text?: string; msgId?: string; mediaKind?: MediaKind }): Promise<SendResult> {
178
+ try {
179
+ const target = parseTarget(to);
180
+ const kind = opts?.mediaKind ?? 'image';
181
+ const resolvedMsgId = resolveMsgId(opts?.msgId, this.accountId);
182
+ if (kind === 'voice') {
183
+ const voiceSource = resolveVoiceSource(source);
184
+ const result = await this.gw.sendVoice(target, voiceSource, { text: opts?.text, msgId: resolvedMsgId });
185
+ return { messageId: result.id };
186
+ }
187
+ if (kind === 'video') {
188
+ const result = await this.gw.sendVideo(target, source, { text: opts?.text, msgId: resolvedMsgId });
189
+ return { messageId: result.id };
190
+ }
191
+ if (kind === 'file') {
192
+ const result = await this.gw.sendFile(target, source, { text: opts?.text, msgId: resolvedMsgId, fileName: path.basename(source) });
193
+ return { messageId: result.id };
194
+ }
195
+ const fileType = MEDIA_KIND_TO_FILE_TYPE[kind];
196
+ const result = await this.gw.sendMedia(target, source, { text: opts?.text, msgId: resolvedMsgId, fileType });
197
+ return { messageId: result.id };
198
+ } catch (err: unknown) { return formatError(err); }
199
+ }
200
+ }
201
+
202
+ // ── 辅助 ──
203
+
204
+ function resolveVoiceSource(source: string): { url?: string; base64?: string; localPath?: string } {
205
+ if (source.startsWith('http://') || source.startsWith('https://')) return { url: source };
206
+ if (source.startsWith('/') || source.startsWith('./') || source.startsWith('../')) return { localPath: source };
207
+ if (source.startsWith('data:')) {
208
+ const i = source.indexOf(',');
209
+ return { base64: i > 0 ? source.slice(i + 1) : source };
210
+ }
211
+ return { base64: source };
212
+ }
213
+
214
+ function formatError(err: unknown): SendResult {
215
+ if (err instanceof Error) {
216
+ const result: SendResult = { error: err.message };
217
+ if ('code' in err) result.errorCode = String((err as any).code);
218
+ if ('qqBizCode' in err) result.qqBizCode = (err as any).qqBizCode;
219
+ return result;
220
+ }
221
+ return { error: String(err) };
222
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 被动回复限额管理
3
+ *
4
+ * QQ Bot 被动回复有限额(同一条消息最多回复 N 次,超时后不能再被动回复)。
5
+ * 当限额耗尽或消息过期时,标记应降级为主动消息(proactive)。
6
+ */
7
+
8
+ export interface ReplyLimiterConfig {
9
+ /** 每条消息最大被动回复次数(默认 4) */
10
+ limit?: number;
11
+ /** 消息 ID 过期时间 ms(默认 1 小时) */
12
+ ttlMs?: number;
13
+ /** 最大跟踪消息数(LRU 驱逐,默认 10000) */
14
+ maxTrackedMessages?: number;
15
+ }
16
+
17
+ export interface ReplyLimitResult {
18
+ allowed: boolean;
19
+ remaining: number;
20
+ shouldFallbackToProactive: boolean;
21
+ fallbackReason?: 'expired' | 'limit_exceeded';
22
+ }
23
+
24
+ interface TrackedMessage {
25
+ count: number;
26
+ firstSeenAt: number;
27
+ }
28
+
29
+ /**
30
+ * 被动回复限额管理器
31
+ *
32
+ * @example
33
+ * ```ts
34
+ * const limiter = new ReplyLimiter({ limit: 4, ttlMs: 3600_000 });
35
+ * const result = limiter.checkLimit(messageId);
36
+ * if (!result.allowed) {
37
+ * // 降级为主动消息
38
+ * } else {
39
+ * limiter.record(messageId);
40
+ * // 正常被动回复
41
+ * }
42
+ * ```
43
+ */
44
+ export class ReplyLimiter {
45
+ private readonly limit: number;
46
+ private readonly ttlMs: number;
47
+ private readonly maxTracked: number;
48
+ private readonly messages = new Map<string, TrackedMessage>();
49
+
50
+ constructor(config?: ReplyLimiterConfig) {
51
+ this.limit = config?.limit ?? 4;
52
+ this.ttlMs = config?.ttlMs ?? 3600_000; // 1 小时
53
+ this.maxTracked = config?.maxTrackedMessages ?? 10_000;
54
+ }
55
+
56
+ /**
57
+ * 检查是否允许对指定消息继续被动回复
58
+ */
59
+ checkLimit(messageId: string): ReplyLimitResult {
60
+ const now = Date.now();
61
+ const tracked = this.messages.get(messageId);
62
+
63
+ if (!tracked) {
64
+ return { allowed: true, remaining: this.limit, shouldFallbackToProactive: false };
65
+ }
66
+
67
+ // TTL 过期
68
+ if (now - tracked.firstSeenAt > this.ttlMs) {
69
+ this.messages.delete(messageId);
70
+ return {
71
+ allowed: false,
72
+ remaining: 0,
73
+ shouldFallbackToProactive: true,
74
+ fallbackReason: 'expired',
75
+ };
76
+ }
77
+
78
+ // 限额检查
79
+ const remaining = Math.max(0, this.limit - tracked.count);
80
+ if (remaining <= 0) {
81
+ return {
82
+ allowed: false,
83
+ remaining: 0,
84
+ shouldFallbackToProactive: true,
85
+ fallbackReason: 'limit_exceeded',
86
+ };
87
+ }
88
+
89
+ return { allowed: true, remaining, shouldFallbackToProactive: false };
90
+ }
91
+
92
+ /**
93
+ * 记录一次被动回复
94
+ */
95
+ record(messageId: string): void {
96
+ const now = Date.now();
97
+ const tracked = this.messages.get(messageId);
98
+
99
+ if (tracked) {
100
+ tracked.count++;
101
+ } else {
102
+ // LRU 驱逐
103
+ if (this.messages.size >= this.maxTracked) {
104
+ const firstKey = this.messages.keys().next().value;
105
+ if (firstKey) this.messages.delete(firstKey);
106
+ }
107
+ this.messages.set(messageId, { count: 1, firstSeenAt: now });
108
+ }
109
+ }
110
+
111
+ /**
112
+ * 获取统计信息
113
+ */
114
+ getStats(): { trackedMessages: number; totalReplies: number } {
115
+ let totalReplies = 0;
116
+ for (const [, v] of this.messages) {
117
+ totalReplies += v.count;
118
+ }
119
+ return { trackedMessages: this.messages.size, totalReplies };
120
+ }
121
+
122
+ /**
123
+ * 清除所有跟踪数据
124
+ */
125
+ clear(): void {
126
+ this.messages.clear();
127
+ }
128
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * QQBot 文本消毒
3
+ *
4
+ * 剥离框架内部脚手架标签(system-reminder、thinking、reasoning 等),
5
+ * 保留 Markdown/HTML 用于 QQ Bot Markdown 渲染。
6
+ */
7
+
8
+ const INTERNAL_TAGS = [
9
+ // 框架脚手架标签
10
+ /<system-reminder\b[^>]*>[\s\S]*?<\/system-reminder>/gi,
11
+ /<previous_response\b[^>]*>[\s\S]*?<\/previous_response>/gi,
12
+ /<\s*\/?\s*(?:system-reminder|previous_response)\b[^>]*\/?\s*>/gi,
13
+ // 模型推理/思考内容
14
+ // deepseek: `think`...`/think` — 匹配完整标签块,标签名 think 必须完整
15
+ // 格式说明:deepseek 用 `` ` `` (反引号) 替代 XML 的 `<` `>` 作为标签定界符
16
+ // `think` ≡ <think> 开标签
17
+ // `/think` ≡ </think> 闭标签
18
+ /`think`[\s\S]*?`\/think`/gi,
19
+ /<\s*\/?\s*think\b[^>]*\/?\s*>/gi,
20
+ // claude: <thinking>...</thinking>
21
+ /<thinking\b[^>]*>[\s\S]*?<\/thinking>/gi,
22
+ /<\s*\/?\s*thinking\b[^>]*\/?\s*>/gi,
23
+ ];
24
+
25
+ /** 剥离内部运行时脚手架块 + 模型推理内容 */
26
+ export function sanitizeQQBotText(text: string): string {
27
+ let result = text;
28
+ for (const re of INTERNAL_TAGS) {
29
+ result = result.replace(re, '');
30
+ }
31
+ return result.trim();
32
+ }
@@ -0,0 +1,228 @@
1
+ /**
2
+ * QQ Bot 流式消息控制器
3
+ *
4
+ * 核心约束:QQ 流式 API 替换模式下,已下发文本的**前缀不可变更**。
5
+ *
6
+ * 状态机:
7
+ * IDLE → (first chunk) → STREAMING → (complete) → DONE
8
+ * → (prefix changed) → DONE
9
+ * → (new reply) → IDLE → …
10
+ *
11
+ * onPartialReply(text) 输入:模型全量文本,持续增长。
12
+ * finalize() 标记:框架通知回复结束,用 lastAccepted 收尾。
13
+ */
14
+
15
+ import type { ReplyTarget, StreamSession } from '@tencent-connect/qqbot-nodejs';
16
+ import type { PluginLogger } from '../utils/plugin-logger.js';
17
+ import type { QQBotGateway } from '../gateway/qqbot-gateway.js';
18
+
19
+ // ── 类型 ──
20
+
21
+ export type StreamingPhase = 'idle' | 'streaming' | 'done' | 'failed';
22
+
23
+ export interface StreamingControllerDeps {
24
+ gateway: QQBotGateway;
25
+ target: ReplyTarget;
26
+ accountId: string;
27
+ replyToId: string;
28
+ log?: PluginLogger;
29
+ }
30
+
31
+ // ── 控制器 ──
32
+
33
+ export class StreamingController {
34
+ private phase: StreamingPhase = 'idle';
35
+ private session: StreamSession | null = null;
36
+
37
+ /** QQ 已接受的最新文本 — 单源真理 */
38
+ private lastAcceptedFull = '';
39
+
40
+ /** 已成功发送的分片数(降级:=0 则走静态消息兜底) */
41
+ private sentChunkCount = 0;
42
+
43
+ /** 同步标志:收到第一个 onPartialReply 即置 true(不等 async 完成) */
44
+ private _hasStarted = false;
45
+
46
+ /** 串行队列 */
47
+ private chain: Promise<unknown> = Promise.resolve();
48
+
49
+ constructor(private readonly deps: StreamingControllerDeps) {}
50
+
51
+ // ── 公共访问器 ──
52
+
53
+ get currentPhase(): StreamingPhase { return this.phase; }
54
+
55
+ /** 是否已成功发送至少一个流式分片 */
56
+ get hasSentChunks(): boolean { return this.sentChunkCount > 0; }
57
+
58
+ /** 同步标志:流式已启动(不等异步完成),用于 final 去重 */
59
+ get hasStarted(): boolean { return this._hasStarted; }
60
+
61
+ get isTerminal(): boolean {
62
+ return this.phase === 'done' || this.phase === 'failed';
63
+ }
64
+
65
+ get shouldFallbackToStatic(): boolean {
66
+ return this.isTerminal && this.sentChunkCount === 0;
67
+ }
68
+
69
+ // ── 入口 ──
70
+
71
+ onPartialReply(text: string): Promise<void> {
72
+ this._hasStarted = true; // 同步置位:不等异步发送,final 就能感知流式已启动
73
+ this.chain = this.chain.then(() => this.handleChunk(text)).catch((err) => {
74
+ this.deps.log?.error(`onPartialReply error: ${err instanceof Error ? err.message : String(err)}`);
75
+ this.transition('failed', 'chunk_error');
76
+ });
77
+ return this.chain as Promise<void>;
78
+ }
79
+
80
+ finalize(): Promise<void> {
81
+ this.chain = this.chain.then(() => this.handleFinalize()).catch((err) => {
82
+ this.deps.log?.error(`finalize error: ${err instanceof Error ? err.message : String(err)}`);
83
+ this.transition('failed', 'finalize_error');
84
+ });
85
+ return this.chain as Promise<void>;
86
+ }
87
+
88
+ async abort(reason?: string): Promise<void> {
89
+ if (this.isTerminal) return;
90
+ this.deps.log?.warn(`aborting stream reason=${reason ?? 'manual'} sent=${this.sentChunkCount}`);
91
+ if (this.session) {
92
+ try { await this.session.complete(); } catch (e) {
93
+ this.deps.log?.error(`abort complete failed: ${e instanceof Error ? e.message : String(e)}`);
94
+ }
95
+ this.session = null;
96
+ }
97
+ this.transition('failed', `abort:${reason ?? 'manual'}`);
98
+ }
99
+
100
+ // ── 核心逻辑 ──
101
+
102
+ private async handleChunk(text: string): Promise<void> {
103
+ if (this.isTerminal || !text) return;
104
+
105
+ // 正常续写:新文本前缀匹配(含空白归一化)
106
+ if (prefixMatches(this.lastAcceptedFull, text)) {
107
+ if (text.length !== this.lastAcceptedFull.length) {
108
+ await this.sendUpdate(text);
109
+ }
110
+ return;
111
+ }
112
+
113
+ // 无已下发内容 → 首发
114
+ if (!this.lastAcceptedFull) {
115
+ await this.sendUpdate(text);
116
+ return;
117
+ }
118
+
119
+ // 前缀不匹配 — 长度回退 → 新回复(工具调用后)
120
+ if (text.length < this.lastAcceptedFull.length) {
121
+ this.deps.log?.info(`new reply: lastAccepted=${this.lastAcceptedFull.length}→chunk=${text.length}`);
122
+ await this.completeSession('new_reply');
123
+ this.lastAcceptedFull = '';
124
+ await this.sendUpdate(text);
125
+ return;
126
+ }
127
+
128
+ // 前缀不匹配但长度增长 → 模型重写尾部,同一条流追加
129
+ const commonLen = longestCommonPrefix(this.lastAcceptedFull, text);
130
+ const extra = text.slice(Math.max(commonLen, 0));
131
+ const merged = this.lastAcceptedFull + extra;
132
+ this.deps.log?.warn(
133
+ `prefix retry: lastAccepted=${this.lastAcceptedFull.length}→chunk=${text.length} common=${commonLen} extra=${extra.length}, appending`,
134
+ );
135
+ await this.sendUpdate(merged);
136
+ }
137
+
138
+ private async handleFinalize(): Promise<void> {
139
+ if (this.isTerminal) return;
140
+
141
+ // 用已下发文本收尾 — 不用框架 deliver 的文本(框架可能追加 ⚠️ 标记)
142
+ if (this.session) {
143
+ await this.completeSession();
144
+ this.transition('done', 'finalize');
145
+ this.deps.log?.info(`stream done chunks=${this.sentChunkCount} chars=${this.lastAcceptedFull.length}`);
146
+ return;
147
+ }
148
+
149
+ // 无会话 — 视有无下发决定终态
150
+ if (this.sentChunkCount > 0) {
151
+ this.transition('done', 'finalize:no_session');
152
+ } else {
153
+ this.transition('failed', 'finalize:fallback');
154
+ }
155
+ }
156
+
157
+ // ── QQ 交互 ──
158
+
159
+ private async sendUpdate(text: string): Promise<void> {
160
+ if (!this.session) {
161
+ this.session = this.deps.gateway.openStream(this.deps.target, this.deps.replyToId);
162
+ this.transition('streaming', 'first_chunk');
163
+ this.deps.log?.info(`stream opened (firstChunk=${text.length})`);
164
+ }
165
+ try {
166
+ await this.session.update(text);
167
+ this.lastAcceptedFull = text;
168
+ this.sentChunkCount++;
169
+ } catch (err) {
170
+ this.deps.log?.error(`update failed (len=${text.length}): ${err instanceof Error ? err.message : String(err)}`);
171
+ this.session = null;
172
+ this.transition('failed', 'update_error');
173
+ }
174
+ }
175
+
176
+ private async completeSession(reason?: string): Promise<void> {
177
+ if (!this.session) return;
178
+ this.deps.log?.info(`completing stream (sent=${this.sentChunkCount} chars=${this.lastAcceptedFull.length} reason=${reason ?? 'done'})`);
179
+ try {
180
+ await this.session.complete();
181
+ } catch (err) {
182
+ this.deps.log?.error(`complete failed: ${err instanceof Error ? err.message : String(err)}`);
183
+ }
184
+ this.session = null;
185
+ }
186
+
187
+ // ── 状态机 ──
188
+
189
+ private transition(next: StreamingPhase, reason: string): void {
190
+ if (this.phase === next) return;
191
+ this.deps.log?.info(`phase: ${this.phase} → ${next} (${reason})`);
192
+ this.phase = next;
193
+ }
194
+ }
195
+
196
+ /** 归一化空白符:连续换行/空格合并为单个空格 */
197
+ function normalizeWs(s: string): string {
198
+ return s.replace(/\s+/g, ' ');
199
+ }
200
+
201
+ /** 忽略空白前缀比较 */
202
+ function prefixMatches(accepted: string, incoming: string): boolean {
203
+ if (incoming.startsWith(accepted)) return true;
204
+ return normalizeWs(incoming).startsWith(normalizeWs(accepted));
205
+ }
206
+
207
+ function longestCommonPrefix(a: string, b: string): number {
208
+ let i = 0;
209
+ while (i < a.length && i < b.length && a[i] === b[i]) i++;
210
+ return i;
211
+ }
212
+
213
+ // ── 入口判断 ──
214
+
215
+ import type { ResolvedQQBotAccount } from '../types.js';
216
+
217
+ export function shouldUseStreaming(
218
+ account: ResolvedQQBotAccount,
219
+ targetScope: 'c2c' | 'group' | 'channel',
220
+ ): boolean {
221
+ if (targetScope !== 'c2c') return false;
222
+ const streaming = account.config?.streaming;
223
+ if (typeof streaming === 'boolean') return streaming;
224
+ if (streaming && typeof streaming === 'object') {
225
+ return (streaming as any).mode !== 'off';
226
+ }
227
+ return false;
228
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 出站目标地址解析
3
+ *
4
+ * 将 OpenClaw 规范的目标地址字符串(如 qqbot:c2c:xxx / qqbot:group:xxx)
5
+ * 转换为 SDK 的 ReplyTarget 结构。
6
+ *
7
+ * 也导出共享的正则常量供 channel.ts messaging 段复用。
8
+ */
9
+ import type { ReplyTarget } from '@tencent-connect/qqbot-nodejs';
10
+
11
+ // ── 共享正则常量 ──
12
+
13
+ /** 32 位十六进制 OpenID(不带连字符) */
14
+ export const OPENID_HEX_RE = /^[0-9a-fA-F]{32}$/;
15
+
16
+ /** UUID 格式的 OpenID(带连字符) */
17
+ export const OPENID_UUID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
18
+
19
+ /** 带 qqbot: 前缀的目标格式 */
20
+ export const QQBOT_PREFIX_RE = /^qqbot:(c2c|group|channel):/i;
21
+
22
+ /** 不带前缀但有 scope 标识 */
23
+ export const SCOPE_PREFIX_RE = /^(c2c|group|channel):/i;
24
+
25
+ /**
26
+ * 判断 ID 是否看起来像 QQ Bot 目标格式
27
+ */
28
+ export function isQQBotTarget(id: string): boolean {
29
+ if (QQBOT_PREFIX_RE.test(id)) return true;
30
+ if (SCOPE_PREFIX_RE.test(id)) return true;
31
+ if (OPENID_HEX_RE.test(id)) return true;
32
+ return OPENID_UUID_RE.test(id);
33
+ }
34
+
35
+ /**
36
+ * 规范化目标地址字符串
37
+ */
38
+ export function normalizeTarget(target: string): string | undefined {
39
+ const id = target.replace(/^qqbot:/i, '');
40
+ if (id.startsWith('c2c:') || id.startsWith('group:') || id.startsWith('channel:')) {
41
+ return `qqbot:${id}`;
42
+ }
43
+ if (OPENID_HEX_RE.test(id)) return `qqbot:c2c:${id}`;
44
+ if (OPENID_UUID_RE.test(id)) return `qqbot:c2c:${id}`;
45
+ return undefined;
46
+ }
47
+
48
+ /**
49
+ * 解析目标地址字符串为 SDK ReplyTarget
50
+ */
51
+ export function parseTarget(to: string): ReplyTarget {
52
+ const id = to.replace(/^qqbot:/i, '');
53
+
54
+ if (id.startsWith('c2c:')) {
55
+ return { scope: 'c2c', targetId: id.slice(4) };
56
+ }
57
+ if (id.startsWith('group:')) {
58
+ return { scope: 'group', targetId: id.slice(6) };
59
+ }
60
+
61
+ // 默认当作 c2c(32 位十六进制 / UUID 格式的 openid)
62
+ return { scope: 'c2c', targetId: id };
63
+ }