@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,352 @@
1
+ /**
2
+ * 消息转发 — 入站消息 → OpenClaw AI
3
+ *
4
+ * 核心职责:
5
+ * 1. 从 SDK MiddlewareContext 构建 OpenClaw 标准信封
6
+ * 2. 通过 runtime-adapter 将消息交给 AI 处理
7
+ *
8
+ * 架构说明:
9
+ * - 所有 runtime.channel.* 访问均通过 runtime-adapter 隔离
10
+ * - log: 前缀由 PluginLogger + 框架自动注入,消息体不重复 accountId
11
+ */
12
+ import type { MiddlewareContext, QQBotInboundMessage } from '@tencent-connect/qqbot-nodejs';
13
+ import type { PluginRuntime } from 'openclaw/plugin-sdk';
14
+ import type { ResolvedQQBotAccount } from '../types.js';
15
+ import type { PluginLogger } from '../utils/plugin-logger.js';
16
+ import { buildEnvelope } from './envelope-builder.js';
17
+ import { assembleBody, type AssembledBody } from './body-assembler.js';
18
+ import { sendText, getGateway } from '../outbound/outbound-service.js';
19
+ import { sendMedia } from '../outbound/media-send.js';
20
+ import { deliverReply, type DeliverPayload, type DeliverInfo, type DeliverContext } from '../outbound/deliver-pipeline.js';
21
+ import { buildCtxPayload } from './ctx-builder.js';
22
+
23
+ import { DeliverDebouncer } from '../outbound/debounce.js';
24
+ import { StreamingController, shouldUseStreaming } from '../outbound/streaming-controller.js';
25
+ import { getAdapters } from '../adapter/resolve.js';
26
+ import { clearGroupHistory } from '../features/history-store.js';
27
+
28
+
29
+ /**
30
+ * 将经过中间件处理的入站消息转发给 OpenClaw AI
31
+ */
32
+ export async function dispatchToOpenClaw(
33
+ ctx: MiddlewareContext,
34
+ msg: QQBotInboundMessage,
35
+ account: ResolvedQQBotAccount,
36
+ runtime: PluginRuntime,
37
+ log?: PluginLogger,
38
+ ): Promise<void> {
39
+ const dlog = log?.child('dispatch');
40
+ const adapters = getAdapters(runtime, dlog);
41
+ const envelope = buildEnvelope(ctx, msg, account);
42
+
43
+ dlog?.debug(`received sender=${envelope.senderId} scope=${envelope.chatScope} msgId=${envelope.messageId}`);
44
+
45
+ if (!adapters.dispatchReply) {
46
+ dlog?.error(`runtime adapter dispatchReply not available (openclaw=${adapters.version})`);
47
+ return;
48
+ }
49
+
50
+ const assembled: AssembledBody =
51
+ ((ctx.state as Record<string, unknown>).assembledBody as AssembledBody | undefined) ??
52
+ assembleBody(ctx, msg, account);
53
+
54
+ const cfg = adapters.getConfig?.() ?? {};
55
+
56
+ const route = adapters.resolveAgentRoute?.({
57
+ cfg,
58
+ channel: 'qqbot',
59
+ accountId: account.accountId,
60
+ peer: {
61
+ kind: envelope.chatScope === 'group' ? 'group' : 'direct',
62
+ id: envelope.chatScope === 'group' ? (envelope.groupId ?? envelope.senderId) : envelope.senderId,
63
+ },
64
+ }) ?? { sessionKey: `qqbot:${account.accountId}:${envelope.senderId}`, accountId: account.accountId };
65
+
66
+ const qualifiedTarget = envelope.targetId;
67
+ const agentId = route.agentId ?? 'default';
68
+ const storePath = adapters.resolveStorePath?.((cfg as any)?.session?.store, { agentId }) ?? '';
69
+
70
+ const ctxPayload = buildCtxPayload({ assembled, envelope, route, msg, ctx, adapters });
71
+
72
+ const ttsRuntime = (runtime as any)?.tts ?? (runtime as any)?.channel?.runtimeContexts?.get?.('tts');
73
+
74
+ const debounceConfig = account.config?.deliverDebounce;
75
+ const debouncer = debounceConfig?.enabled !== false
76
+ ? new DeliverDebouncer(debounceConfig, (targetId, mergedText) =>
77
+ sendText({ to: targetId, text: mergedText, accountId: account.accountId, replyToId: envelope.messageId, account }).then(() => {}),
78
+ )
79
+ : undefined;
80
+
81
+ const deliverCtx: DeliverContext = {
82
+ qualifiedTarget,
83
+ accountId: account.accountId,
84
+ replyToId: envelope.messageId,
85
+ chatScope: envelope.chatScope === 'group' ? 'group' : 'direct',
86
+ cfg,
87
+ debouncer: debouncer?.enabled ? debouncer : undefined,
88
+ sendText: (to, text) => sendText({ to, text, accountId: account.accountId, replyToId: envelope.messageId, account }),
89
+ sendMedia: (to, source, opts) => sendMedia({
90
+ to,
91
+ source,
92
+ text: opts?.text ?? '',
93
+ replyToId: envelope.messageId,
94
+ accountId: account.accountId,
95
+ agentId: route.agentId,
96
+ log: deliverCtx.log,
97
+ }),
98
+ textToSpeech: ttsRuntime?.textToSpeech
99
+ ? (params) => ttsRuntime.textToSpeech(params)
100
+ : undefined,
101
+ audioFileToSilkBase64: ttsRuntime?.audioFileToSilkBase64
102
+ ? (audioPath: string) => ttsRuntime.audioFileToSilkBase64(audioPath)
103
+ : undefined,
104
+ log: log?.child('deliver'),
105
+ agentId: route.agentId ?? 'default',
106
+ };
107
+
108
+ const streamingEnabled = shouldUseStreaming(
109
+ account,
110
+ envelope.chatScope === 'group' ? 'group' : 'c2c',
111
+ );
112
+
113
+ const streamingController = streamingEnabled
114
+ ? createStreamingController(envelope, account, log?.child('streaming'))
115
+ : null;
116
+
117
+ if (streamingController) {
118
+ dlog?.debug(`streaming enabled for ${envelope.senderId}`);
119
+ }
120
+
121
+ const deliveredMediaUrls = new Set<string>();
122
+ const deliveredTexts = new Set<string>();
123
+
124
+ if (!adapters.inboundRun) {
125
+ // 低版本:手动 session + dispatchReply 直调
126
+ if (adapters.recordInboundSession) {
127
+ try {
128
+ await adapters.recordInboundSession({
129
+ storePath,
130
+ sessionKey: route.sessionKey,
131
+ ctx: ctxPayload,
132
+ });
133
+ } catch { /* best-effort */ }
134
+ }
135
+ await adapters.dispatchReply!({
136
+ ctx: ctxPayload,
137
+ cfg,
138
+ dispatcherOptions: {
139
+ deliver: async (payload: DeliverPayload, info?: DeliverInfo) => {
140
+ const text = payload.text?.trim() ?? '';
141
+ // 低版本无 block/final 协议,流式启动后 final 仍需跳过(降级除外)
142
+ if (!payload.mediaUrl && !payload.mediaUrls?.length
143
+ && text
144
+ && (deliveredTexts.has(text)
145
+ || (streamingController?.hasStarted && !streamingController?.shouldFallbackToStatic))
146
+ ) {
147
+ return;
148
+ }
149
+ const filteredPayload = deliveredMediaUrls.size > 0
150
+ ? {
151
+ ...payload,
152
+ mediaUrl: payload.mediaUrl && !deliveredMediaUrls.has(payload.mediaUrl)
153
+ ? payload.mediaUrl : undefined,
154
+ mediaUrls: payload.mediaUrls?.filter((u) => !deliveredMediaUrls.has(u)),
155
+ }
156
+ : payload;
157
+ await deliverReply(filteredPayload, info, deliverCtx);
158
+ if (text) deliveredTexts.add(text);
159
+ // 记录已投递媒体(流式路径可能已先发送)
160
+ for (const u of filteredPayload.mediaUrls ?? []) deliveredMediaUrls.add(u);
161
+ if (filteredPayload.mediaUrl) deliveredMediaUrls.add(filteredPayload.mediaUrl);
162
+ },
163
+ },
164
+ replyOptions: {
165
+ abortSignal: ctx.signal,
166
+ runId: envelope.messageId,
167
+ ...(streamingController
168
+ ? {
169
+ onPartialReply: async (p: { text?: string }) => {
170
+ if (p.text) await streamingController.onPartialReply(p.text);
171
+ },
172
+ }
173
+ : {}),
174
+ },
175
+ });
176
+ if (streamingController && !streamingController.isTerminal) {
177
+ await streamingController.finalize();
178
+ }
179
+ if (debouncer) await debouncer.flushAll();
180
+ } else {
181
+ await adapters.inboundRun!({
182
+ channel: 'qqbot',
183
+ accountId: route.accountId,
184
+ raw: envelope,
185
+ adapter: {
186
+ ingest: (raw: any) => ({
187
+ id: envelope.messageId,
188
+ rawText: assembled.rawBody,
189
+ textForAgent: assembled.agentBody,
190
+ textForCommands: assembled.rawBody,
191
+ raw,
192
+ }),
193
+ resolveTurn: (_input: unknown, _eventClass: unknown, _preflight: unknown) => ({
194
+ channel: 'qqbot',
195
+ accountId: route.accountId,
196
+ routeSessionKey: route.sessionKey,
197
+ storePath,
198
+ ctxPayload,
199
+ recordInboundSession: adapters.recordInboundSession,
200
+ record: {
201
+ onRecordError: (err: unknown) => {
202
+ dlog?.error(`Session record error: ${err}`);
203
+ },
204
+ },
205
+ runDispatch: () => {
206
+ return adapters.dispatchReply!({
207
+ ctx: ctxPayload,
208
+ cfg,
209
+ dispatcherOptions: {
210
+ deliver: async (payload: DeliverPayload, info?: DeliverInfo) => {
211
+ try {
212
+ const kind = (info as any)?.kind as string | undefined;
213
+ const text = payload.text?.trim() ?? '';
214
+ const hasMedia = !!(payload.mediaUrl || payload.mediaUrls?.length);
215
+ dlog?.debug(`deliver kind=${kind ?? 'none'} textLen=${text.length} voice=${!!payload.audioAsVoice} media=${hasMedia}`);
216
+
217
+ // ── 1. block: 媒体/语音立即发送,文本留给流式 ──
218
+ if (kind === 'block') {
219
+ if (payload.audioAsVoice) {
220
+ await deliverReply(payload, info, deliverCtx);
221
+ } else {
222
+ await forwardMediaUrls(payload, deliverCtx, deliveredMediaUrls, dlog);
223
+ }
224
+ }
225
+
226
+ // ── 2. 流式路径:流式已启动且未降级 → 跳过静态发送 ──
227
+ if (streamingController?.hasStarted && !streamingController.shouldFallbackToStatic) {
228
+ if (kind !== 'block') await streamingController.finalize();
229
+ if (!streamingController.shouldFallbackToStatic) return;
230
+ dlog?.warn(`streaming fallback to static`);
231
+ }
232
+
233
+ // ── 3. 文本去重:同文本已发过 → 跳过 ──
234
+ if (kind === 'final' && !hasMedia && text && deliveredTexts.has(text)) {
235
+ return;
236
+ }
237
+
238
+ // ── 4. tool 媒体:立即转发 ──
239
+ if (kind === 'tool') {
240
+ await forwardMediaUrls(payload, deliverCtx, deliveredMediaUrls, dlog);
241
+ return;
242
+ }
243
+
244
+ // ── 5. 默认路径:过滤已发媒体 + 发送 ──
245
+ const filteredPayload = filterDeliveredMedia(payload, deliveredMediaUrls);
246
+ await deliverReply(filteredPayload, info, deliverCtx);
247
+ if (text) deliveredTexts.add(text);
248
+ } catch (err) {
249
+ dlog?.error(`deliver error: ${err instanceof Error ? err.message : String(err)}`);
250
+ }
251
+ },
252
+ },
253
+ replyOptions: {
254
+ abortSignal: ctx.signal,
255
+ runId: envelope.messageId,
256
+ ...(streamingController
257
+ ? {
258
+ onPartialReply: async (p: { text?: string }) => {
259
+ if (p.text) await streamingController.onPartialReply(p.text);
260
+ },
261
+ }
262
+ : {}),
263
+ },
264
+ });
265
+ },
266
+ }),
267
+ },
268
+ });
269
+ }
270
+
271
+ dlog?.debug(`inboundRun completed sessionKey=${route.sessionKey}`);
272
+
273
+ // 群消息回复后清空历史缓存(避免下次 @ 时重复组包)
274
+ if (envelope.chatScope === 'group') {
275
+ clearGroupHistory(account.accountId, envelope.groupId ?? envelope.senderId);
276
+ }
277
+
278
+ if (streamingController && !streamingController.isTerminal) {
279
+ await streamingController.finalize();
280
+ }
281
+
282
+ if (debouncer) {
283
+ await debouncer.flushAll();
284
+ }
285
+ }
286
+
287
+ function createStreamingController(
288
+ envelope: ReturnType<typeof buildEnvelope>,
289
+ account: ResolvedQQBotAccount,
290
+ log?: PluginLogger,
291
+ ): StreamingController | null {
292
+ const gw = getGateway(account.accountId);
293
+ if (!gw) {
294
+ log?.error(`cannot enable streaming — gateway not running`);
295
+ return null;
296
+ }
297
+ return new StreamingController({
298
+ gateway: gw,
299
+ target: {
300
+ scope: 'c2c',
301
+ targetId: envelope.senderId,
302
+ msgId: envelope.messageId,
303
+ },
304
+ accountId: account.accountId,
305
+ replyToId: envelope.messageId,
306
+ log,
307
+ });
308
+ }
309
+
310
+ // ── 辅助函数 ──
311
+
312
+ /** 提取 payload 中的媒体 URL 并逐个发送(去重) */
313
+ async function forwardMediaUrls(
314
+ payload: DeliverPayload,
315
+ ctx: DeliverContext,
316
+ delivered: Set<string>,
317
+ log?: PluginLogger,
318
+ ): Promise<void> {
319
+ const urls: string[] = [];
320
+ if (payload.mediaUrls?.length) urls.push(...payload.mediaUrls);
321
+ if (payload.mediaUrl && !urls.includes(payload.mediaUrl)) urls.push(payload.mediaUrl);
322
+ const newUrls = urls.filter((u) => !delivered.has(u));
323
+ for (const url of newUrls) {
324
+ try {
325
+ await sendMedia({
326
+ to: ctx.qualifiedTarget,
327
+ source: url,
328
+ text: '',
329
+ replyToId: ctx.replyToId,
330
+ accountId: ctx.accountId,
331
+ log: ctx.log,
332
+ agentId: ctx.agentId,
333
+ });
334
+ delivered.add(url);
335
+ } catch (err) {
336
+ log?.error(`media forward failed: ${err instanceof Error ? err.message : String(err)}`);
337
+ }
338
+ }
339
+ }
340
+
341
+ /** 过滤已发送的媒体 URL */
342
+ function filterDeliveredMedia(
343
+ payload: DeliverPayload,
344
+ delivered: Set<string>,
345
+ ): DeliverPayload {
346
+ if (delivered.size === 0) return payload;
347
+ return {
348
+ ...payload,
349
+ mediaUrl: payload.mediaUrl && !delivered.has(payload.mediaUrl) ? payload.mediaUrl : undefined,
350
+ mediaUrls: payload.mediaUrls?.filter((u) => !delivered.has(u)),
351
+ };
352
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * 信封构建器
3
+ *
4
+ * 将 SDK MiddlewareContext 中由各中间件填充的 state 数据
5
+ * 转换为 OpenClaw 框架标准的 InboundMessage 结构。
6
+ */
7
+ import type { MiddlewareContext, QQBotInboundMessage, HistoryEntry, ResolvedQuote } from '@tencent-connect/qqbot-nodejs';
8
+ import type { ResolvedQQBotAccount } from '../types.js';
9
+
10
+ /**
11
+ * OpenClaw 入站消息结构(传递给 runtime.channel.dispatch)
12
+ */
13
+ export interface OpenClawInboundMessage {
14
+ channelId: string;
15
+ accountId: string;
16
+ targetId: string;
17
+ chatScope: 'direct' | 'group';
18
+ senderId: string;
19
+ senderName?: string;
20
+ messageId: string;
21
+ content: string;
22
+ history?: Array<{ role: string; content: string; senderId?: string; senderName?: string }>;
23
+ quote?: { content: string; senderId: string; attachments?: unknown[] };
24
+ attachments?: unknown[];
25
+ /** 入站图片 URL 列表(从附件中提取) */
26
+ imageUrls?: string[];
27
+ groupId?: string;
28
+ systemPrompt?: string;
29
+ }
30
+
31
+ /**
32
+ * 将 HistoryEntry 转换为 OpenClaw 历史格式
33
+ */
34
+ function mapHistory(
35
+ entries: HistoryEntry[] | undefined,
36
+ ): OpenClawInboundMessage['history'] {
37
+ if (!entries || entries.length === 0) return undefined;
38
+ return entries.map((e) => ({
39
+ role: 'user',
40
+ content: e.content,
41
+ senderId: e.senderId,
42
+ senderName: e.senderName,
43
+ }));
44
+ }
45
+
46
+ /**
47
+ * 将 ResolvedQuote 转换为 OpenClaw quote 格式
48
+ */
49
+ function mapQuote(
50
+ quote: ResolvedQuote | undefined,
51
+ ): OpenClawInboundMessage['quote'] {
52
+ if (!quote) return undefined;
53
+ return {
54
+ content: quote.text,
55
+ senderId: quote.entry?.senderId ?? '',
56
+ attachments: quote.attachments as unknown[],
57
+ };
58
+ }
59
+
60
+ /**
61
+ * 从 SDK 中间件上下文构建 OpenClaw InboundMessage
62
+ */
63
+ export function buildEnvelope(
64
+ ctx: MiddlewareContext,
65
+ msg: QQBotInboundMessage,
66
+ account: ResolvedQQBotAccount,
67
+ ): OpenClawInboundMessage {
68
+ const scope = msg.replyTarget.scope;
69
+ const targetId = scope === 'group'
70
+ ? `qqbot:group:${msg.replyTarget.targetId}`
71
+ : `qqbot:c2c:${msg.replyTarget.targetId}`;
72
+
73
+ // envelope 是 string 类型,直接作为 content 使用;回退到原始消息内容
74
+ const envelope = ctx.state.envelope as string | undefined;
75
+ let content = envelope ?? msg.content;
76
+
77
+ // 将语音转录文本和附件信息注入到 content 中
78
+ const processed = ctx.state.processedAttachments as
79
+ | { voiceText?: string; imageUrls?: string[]; otherInfo?: string }
80
+ | undefined;
81
+
82
+ if (processed) {
83
+ const parts: string[] = [];
84
+ if (processed.voiceText) {
85
+ parts.push(processed.voiceText);
86
+ }
87
+ if (content) {
88
+ parts.push(content);
89
+ }
90
+ if (processed.otherInfo) {
91
+ parts.push(processed.otherInfo);
92
+ }
93
+ content = parts.join('\n');
94
+ }
95
+
96
+ return {
97
+ channelId: 'qqbot',
98
+ accountId: account.accountId,
99
+ targetId,
100
+ chatScope: scope === 'group' ? 'group' : 'direct',
101
+ senderId: msg.senderId,
102
+ senderName: msg.senderName,
103
+ messageId: msg.messageId,
104
+ content,
105
+ history: mapHistory(ctx.state.history as HistoryEntry[] | undefined),
106
+ quote: mapQuote(ctx.state.quote as ResolvedQuote | undefined),
107
+ attachments: msg.attachments,
108
+ imageUrls: processed?.imageUrls,
109
+ groupId: scope === 'group' ? msg.replyTarget.targetId : undefined,
110
+ systemPrompt: account.systemPrompt,
111
+ };
112
+ }
@@ -0,0 +1,2 @@
1
+ export { dispatchToOpenClaw } from './dispatch.js';
2
+ export { buildEnvelope } from './envelope-builder.js';
@@ -10,75 +10,11 @@
10
10
  */
11
11
 
12
12
  import type { OpenClawConfig } from "openclaw/plugin-sdk";
13
- import { createRequire } from "node:module";
14
- import path from "node:path";
15
- import { fileURLToPath } from "node:url";
16
- import {
17
- getAccessToken,
18
- sendC2CMessageWithInlineKeyboard,
19
- sendGroupMessageWithInlineKeyboard,
20
- } from "./api.js";
21
- import type { InlineKeyboard, KeyboardButton } from "./types.js";
22
-
23
- // ─── 动态加载 gateway-runtime(兼容不同安装环境) ────────
24
-
25
- function loadGatewayRuntime(): { createOperatorApprovalsGatewayClient: (...args: any[]) => Promise<GatewayClient> } {
26
- const req = createRequire(import.meta.url);
27
- const currentFile = fileURLToPath(import.meta.url);
28
- const pluginRoot = path.resolve(path.dirname(currentFile), "..", "..");
29
- const fs = req("node:fs") as typeof import("node:fs");
30
-
31
- // 尝试从找到的 openclaw 根目录加载 gateway-runtime.js
32
- const tryLoadFromRoot = (root: string) => {
33
- for (const rel of ["dist/plugin-sdk/gateway-runtime.js", "plugin-sdk/gateway-runtime.js"]) {
34
- const p = path.join(root, rel);
35
- try {
36
- if (fs.existsSync(p)) return req(p);
37
- } catch { /* try next */ }
38
- }
39
- return null;
40
- };
41
-
42
- // 策略 1: link-sdk-core.cjs findOpenclawRoot
43
- try {
44
- const { findOpenclawRoot } = req(path.join(pluginRoot, "scripts", "link-sdk-core.cjs")) as {
45
- findOpenclawRoot: (root: string) => string | null;
46
- };
47
- const root = findOpenclawRoot(pluginRoot);
48
- if (root) {
49
- const mod = tryLoadFromRoot(root);
50
- if (mod) return mod;
51
- }
52
- } catch { /* fallback */ }
53
-
54
- // 策略 2: process.argv[1] 反推(当前进程就是 openclaw)
55
- try {
56
- const entry = process.argv[1];
57
- if (entry) {
58
- const realEntry = fs.realpathSync(entry);
59
- let dir = path.dirname(realEntry);
60
- for (let i = 0; i < 6; i++) {
61
- const mod = tryLoadFromRoot(dir);
62
- if (mod) return mod;
63
- const parent = path.dirname(dir);
64
- if (parent === dir) break;
65
- dir = parent;
66
- }
67
- }
68
- } catch { /* fallback */ }
69
-
70
- throw new Error("Cannot find openclaw/plugin-sdk/gateway-runtime (all strategies failed)");
71
- }
72
-
73
- // ─── 动态加载的模块类型(兼容旧版框架) ───────────────────────
74
-
75
- /** gateway-runtime 模块的接口(动态 import) */
76
- type GatewayClient = {
77
- start: () => void | Promise<void>;
78
- stop: () => void | Promise<void>;
79
- request: (method: string, params: unknown) => Promise<unknown>;
80
- };
13
+ import { getBotForAccount } from "../bot-instance.js";
14
+ import type { PluginLogger } from '../utils/plugin-logger.js';
15
+ import type { InlineKeyboard, KeyboardButton } from "../types.js";
81
16
 
17
+ import { loadApprovalGatewayRuntime, type ApprovalGatewayClient } from '../adapter/gateway.js';
82
18
  type EventFrame = {
83
19
  event: string;
84
20
  payload: unknown;
@@ -139,11 +75,7 @@ export interface QQBotApprovalHandlerOpts {
139
75
  clientSecret: string;
140
76
  cfg: OpenClawConfig;
141
77
  gatewayUrl?: string;
142
- log?: {
143
- info: (msg: string) => void;
144
- error: (msg: string) => void;
145
- debug?: (msg: string) => void;
146
- };
78
+ log?: PluginLogger;
147
79
  }
148
80
 
149
81
  type ApprovalKind = "exec" | "plugin";
@@ -255,11 +187,12 @@ function resolveTarget(
255
187
  // ─── Handler 类 ──────────────────────────────────────────────
256
188
 
257
189
  export class QQBotApprovalHandler {
258
- private gatewayClient: GatewayClient | null = null;
190
+ private gatewayClient: ApprovalGatewayClient | null = null;
259
191
  private pending = new Map<string, PendingEntry>();
260
192
  private requestCache = new Map<string, CachedApprovalRequest>();
261
193
  private opts: QQBotApprovalHandlerOpts;
262
194
  private started = false;
195
+ private connected = false;
263
196
 
264
197
  constructor(opts: QQBotApprovalHandlerOpts) {
265
198
  this.opts = opts;
@@ -269,14 +202,12 @@ export class QQBotApprovalHandler {
269
202
  if (this.started) return;
270
203
  this.started = true;
271
204
  const { log } = this.opts;
272
- log?.info(`[qqbot:${this.opts.accountId}] approval-handler: starting`);
205
+ log?.debug?.(`[qqbot:${this.opts.accountId}] approval-handler: starting`);
273
206
 
274
207
  // 动态加载 gateway-runtime(兼容旧版框架 / pnpm 环境)
275
- let gatewayRuntime: { createOperatorApprovalsGatewayClient: (...args: any[]) => Promise<GatewayClient> };
276
- try {
277
- gatewayRuntime = loadGatewayRuntime();
278
- } catch (err) {
279
- log?.error(`[qqbot:${this.opts.accountId}] approval-handler: gateway-runtime module not available, approval feature disabled. Error: ${err}`);
208
+ const gatewayRuntime = loadApprovalGatewayRuntime();
209
+ if (!gatewayRuntime) {
210
+ log?.debug?.(`[qqbot:${this.opts.accountId}] approval-handler: gateway-runtime not available, approval disabled`);
280
211
  this.started = false;
281
212
  return;
282
213
  }
@@ -287,9 +218,15 @@ export class QQBotApprovalHandler {
287
218
  gatewayUrl: this.opts.gatewayUrl,
288
219
  clientDisplayName: "QQBot Approval Handler",
289
220
  onEvent: (evt: EventFrame) => this.handleGatewayEvent(evt),
290
- onHelloOk: () => log?.info(`[qqbot:${this.opts.accountId}] approval-handler: connected to gateway`),
221
+ onHelloOk: () => {
222
+ this.connected = true;
223
+ log?.debug?.(`[qqbot:${this.opts.accountId}] approval-handler: connected to gateway`);
224
+ },
291
225
  onConnectError: (err: { message: string }) => log?.error(`[qqbot:${this.opts.accountId}] approval-handler: connect error: ${err.message}`),
292
- onClose: (code: number, reason: string) => log?.debug?.(`[qqbot:${this.opts.accountId}] approval-handler: gateway closed: ${code} ${reason}`),
226
+ onClose: (code: number, reason: string) => {
227
+ this.connected = false;
228
+ log?.debug?.(`[qqbot:${this.opts.accountId}] approval-handler: gateway closed: ${code} ${reason}`);
229
+ },
293
230
  });
294
231
  this.gatewayClient.start();
295
232
  setApprovalFeatureAvailable(true);
@@ -305,11 +242,14 @@ export class QQBotApprovalHandler {
305
242
  for (const entry of this.pending.values()) clearTimeout(entry.timeoutId);
306
243
  this.pending.clear();
307
244
  this.requestCache.clear();
308
- this.gatewayClient?.stop();
245
+ await this.gatewayClient?.stop();
309
246
  this.gatewayClient = null;
310
- this.opts.log?.info(`[qqbot:${this.opts.accountId}] approval-handler: stopped`);
247
+ this.opts.log?.debug(`[qqbot:${this.opts.accountId}] approval-handler: stopped`);
311
248
  }
312
249
 
250
+ /** gateway 是否已建立连接 */
251
+ get isConnected(): boolean { return this.connected; }
252
+
313
253
  /** 检查是否有指定 shortId 对应的 pending 审批 */
314
254
  hasShortId(shortId: string): boolean {
315
255
  for (const id of this.pending.keys()) {
@@ -323,7 +263,10 @@ export class QQBotApprovalHandler {
323
263
  approvalId: string,
324
264
  decision: "allow-once" | "allow-always" | "deny"
325
265
  ): Promise<boolean> {
326
- if (!this.gatewayClient) return false;
266
+ if (!this.gatewayClient) {
267
+ this.opts.log?.warn(`[qqbot:${this.opts.accountId}] approval-handler: resolve ignored ${approvalId} → gatewayClient not ready`);
268
+ return false;
269
+ }
327
270
 
328
271
  // 查找完整 ID:支持完整 ID(exec:uuid / plugin:uuid)、纯 UUID、或 shortId(8位)
329
272
  let fullId = approvalId;
@@ -351,11 +294,11 @@ export class QQBotApprovalHandler {
351
294
  const isPending = this.pending.has(fullId);
352
295
  const isCached = this.requestCache.has(fullId);
353
296
 
354
- this.opts.log?.info(`[qqbot:${this.opts.accountId}] approval-handler: resolving ${fullId} (input=${approvalId}) kind=${kind} → ${decision}, pending=${isPending}, cached=${isCached}`);
297
+ this.opts.log?.debug?.(`[qqbot:${this.opts.accountId}] approval-handler: resolving ${fullId} (input=${approvalId}) kind=${kind} → ${decision}, pending=${isPending}, cached=${isCached}`);
355
298
 
356
299
  try {
357
300
  await this.gatewayClient.request(method, { id: fullId, decision });
358
- this.opts.log?.info(`[qqbot:${this.opts.accountId}] approval-handler: RPC success ${toShortId(fullId)} → ${decision} (method=${method})`);
301
+ this.opts.log?.debug(`[qqbot:${this.opts.accountId}] approval-handler: RPC success ${toShortId(fullId)} → ${decision} (method=${method})`);
359
302
  return true;
360
303
  } catch (err) {
361
304
  this.opts.log?.error(`[qqbot:${this.opts.accountId}] approval-handler: resolve failed: ${err}`);
@@ -384,7 +327,10 @@ export class QQBotApprovalHandler {
384
327
 
385
328
  // 只处理本账号的请求
386
329
  const reqAccountId = (request.request as any).turnSourceAccountId?.trim();
387
- if (reqAccountId && reqAccountId !== accountId) return;
330
+ if (reqAccountId && reqAccountId !== accountId) {
331
+ log?.debug?.(`[qqbot:${accountId}] approval-handler: ${kind} ${shortId} ignored → account mismatch (req=${reqAccountId})`);
332
+ return;
333
+ }
388
334
 
389
335
  // 解析投递目标
390
336
  const sessionKey = (request.request as any).sessionKey;
@@ -419,12 +365,12 @@ export class QQBotApprovalHandler {
419
365
  await new Promise((r) => setTimeout(r, 2000));
420
366
 
421
367
  try {
422
- const token = await getAccessToken(appId, clientSecret);
423
- if (target.type === "c2c") {
424
- await sendC2CMessageWithInlineKeyboard(token, target.id, text, keyboard);
425
- } else {
426
- await sendGroupMessageWithInlineKeyboard(token, target.id, text, keyboard);
427
- }
368
+ const bot = getBotForAccount(accountId);
369
+ const target_reply = {
370
+ scope: target.type as "c2c" | "group",
371
+ targetId: target.id,
372
+ };
373
+ await bot.sendTextWithKeyboard(target_reply, text, keyboard as any);
428
374
  log?.info(`[qqbot:${accountId}] approval-handler: sent ${kind} approval ${shortId}`);
429
375
 
430
376
  const timeoutId = setTimeout(() => {