@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,130 @@
1
+ /**
2
+ * Cron 定时消息
3
+ *
4
+ * 当 AI 输出 `QQBOT_PAYLOAD: { "type": "cron_reminder", "cron": "...", "text": "..." }`
5
+ * 时,注册一个定时任务在指定时间向用户发送提醒消息。
6
+ *
7
+ * 使用 setTimeout 实现(适合单次/短期提醒),不依赖外部调度库。
8
+ * 复杂 cron 表达式场景需要用户自行接入 node-cron 等库。
9
+ */
10
+
11
+ export interface CronReminderPayload {
12
+ /** 提醒文本 */
13
+ text: string;
14
+ /** 触发时间:ISO 8601 字符串 或 Unix ms 或 "in Xm/Xh/Xd" 相对格式 */
15
+ at?: string | number;
16
+ /** cron 表达式(暂不支持,仅做记录) */
17
+ cron?: string;
18
+ /** 发送目标 */
19
+ target?: string;
20
+ }
21
+
22
+ export interface ScheduledReminder {
23
+ id: string;
24
+ text: string;
25
+ target: string;
26
+ triggerAt: number;
27
+ timer?: ReturnType<typeof setTimeout>;
28
+ }
29
+
30
+ type SendFn = (target: string, text: string) => Promise<void>;
31
+
32
+ /**
33
+ * 定时消息调度器
34
+ */
35
+ export class CronScheduler {
36
+ private reminders = new Map<string, ScheduledReminder>();
37
+ private nextId = 0;
38
+
39
+ constructor(private readonly send: SendFn) {}
40
+
41
+ /**
42
+ * 注册一个定时提醒
43
+ */
44
+ schedule(payload: CronReminderPayload, defaultTarget: string): { id: string; triggerAt: number; confirmText: string } {
45
+ const id = `reminder_${++this.nextId}`;
46
+ const target = payload.target ?? defaultTarget;
47
+ const triggerAt = resolveTriggerTime(payload.at);
48
+ const delayMs = Math.max(0, triggerAt - Date.now());
49
+ const text = payload.text?.trim() ?? '定时提醒';
50
+
51
+ const reminder: ScheduledReminder = {
52
+ id,
53
+ text,
54
+ target,
55
+ triggerAt,
56
+ };
57
+
58
+ // 如果延迟小于 24 小时,直接 setTimeout;否则只记录不调度
59
+ const MAX_TIMEOUT = 24 * 60 * 60 * 1000; // 24h
60
+ if (delayMs <= MAX_TIMEOUT) {
61
+ reminder.timer = setTimeout(async () => {
62
+ this.reminders.delete(id);
63
+ try {
64
+ await this.send(target, `⏰ ${text}`);
65
+ } catch { /* swallow */ }
66
+ }, delayMs);
67
+ }
68
+
69
+ this.reminders.set(id, reminder);
70
+
71
+ const when = new Date(triggerAt);
72
+ const confirmText = `✅ Reminder scheduled for ${when.toLocaleString()}: "${text}"`;
73
+ return { id, triggerAt, confirmText };
74
+ }
75
+
76
+ /**
77
+ * 取消一个提醒
78
+ */
79
+ cancel(id: string): boolean {
80
+ const reminder = this.reminders.get(id);
81
+ if (!reminder) return false;
82
+ if (reminder.timer) clearTimeout(reminder.timer);
83
+ this.reminders.delete(id);
84
+ return true;
85
+ }
86
+
87
+ /**
88
+ * 清除所有提醒
89
+ */
90
+ clear(): void {
91
+ for (const [, r] of this.reminders) {
92
+ if (r.timer) clearTimeout(r.timer);
93
+ }
94
+ this.reminders.clear();
95
+ }
96
+
97
+ /**
98
+ * 获取所有待触发的提醒
99
+ */
100
+ listPending(): ScheduledReminder[] {
101
+ return [...this.reminders.values()];
102
+ }
103
+ }
104
+
105
+ // ── 时间解析 ──
106
+
107
+ function resolveTriggerTime(at: string | number | undefined): number {
108
+ if (!at) return Date.now() + 60_000; // 默认 1 分钟后
109
+
110
+ if (typeof at === 'number') return at;
111
+
112
+ // ISO 8601
113
+ const dateMs = new Date(at).getTime();
114
+ if (!Number.isNaN(dateMs)) return dateMs;
115
+
116
+ // 相对格式: "in 5m", "in 2h", "in 1d"
117
+ const relMatch = at.match(/^(?:in\s+)?(\d+)\s*(m|min|h|hour|d|day|s|sec)/i);
118
+ if (relMatch) {
119
+ const val = Number(relMatch[1]);
120
+ const unit = relMatch[2].toLowerCase();
121
+ const multiplier =
122
+ unit.startsWith('s') ? 1000 :
123
+ unit.startsWith('m') ? 60_000 :
124
+ unit.startsWith('h') ? 3600_000 :
125
+ unit.startsWith('d') ? 86400_000 : 60_000;
126
+ return Date.now() + val * multiplier;
127
+ }
128
+
129
+ return Date.now() + 60_000;
130
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * 出站消息合并防抖
3
+ *
4
+ * 当短时间内收到多次 deliver 时,将文本合并为一条消息发送,避免消息轰炸。
5
+ *
6
+ * 注:框架 ReplyDispatcher 通过 sendChain 保证 deliver 严格串行——
7
+ * enqueue 返回的 Promise 在文本实际发出前不会 resolve,因此上游
8
+ * 不会并发调用 deliver,无需 flushBeforeMedia。
9
+ */
10
+ import type { DeliverDebounceConfig } from '../types.js';
11
+
12
+ const DEFAULT_WINDOW_MS = 1500;
13
+ const DEFAULT_MAX_WAIT_MS = 8000;
14
+ const DEFAULT_SEPARATOR = '\n\n---\n\n';
15
+
16
+ interface PendingDeliver {
17
+ texts: string[];
18
+ firstAt: number;
19
+ timer: ReturnType<typeof setTimeout> | null;
20
+ resolve: () => void;
21
+ }
22
+
23
+ export class DeliverDebouncer {
24
+ private pending = new Map<string, PendingDeliver>();
25
+ private readonly windowMs: number;
26
+ private readonly maxWaitMs: number;
27
+ private readonly separator: string;
28
+ private readonly flush: (targetId: string, mergedText: string) => Promise<void>;
29
+
30
+ constructor(
31
+ config: DeliverDebounceConfig | undefined,
32
+ flush: (targetId: string, mergedText: string) => Promise<void>,
33
+ ) {
34
+ this.windowMs = config?.windowMs ?? DEFAULT_WINDOW_MS;
35
+ this.maxWaitMs = config?.maxWaitMs ?? DEFAULT_MAX_WAIT_MS;
36
+ this.separator = config?.separator ?? DEFAULT_SEPARATOR;
37
+ this.flush = flush;
38
+ }
39
+
40
+ get enabled(): boolean {
41
+ return this.windowMs > 0;
42
+ }
43
+
44
+ /**
45
+ * 入队一条纯文本待发送消息
46
+ * @returns Promise,在文本实际发送时 resolve
47
+ */
48
+ async enqueue(targetId: string, text: string): Promise<void> {
49
+ if (!this.enabled) {
50
+ await this.flush(targetId, text);
51
+ return;
52
+ }
53
+
54
+ const existing = this.pending.get(targetId);
55
+ if (existing) {
56
+ existing.texts.push(text);
57
+ // 重置窗口定时器
58
+ if (existing.timer) clearTimeout(existing.timer);
59
+ // 检查是否超过最大等待
60
+ if (Date.now() - existing.firstAt >= this.maxWaitMs) {
61
+ await this.doFlush(targetId);
62
+ } else {
63
+ existing.timer = setTimeout(() => this.doFlush(targetId), this.windowMs);
64
+ }
65
+ return;
66
+ }
67
+
68
+ // 新建 pending
69
+ return new Promise<void>((resolve) => {
70
+ const pending: PendingDeliver = {
71
+ texts: [text],
72
+ firstAt: Date.now(),
73
+ timer: setTimeout(() => this.doFlush(targetId), this.windowMs),
74
+ resolve,
75
+ };
76
+ this.pending.set(targetId, pending);
77
+ });
78
+ }
79
+
80
+ private async doFlush(targetId: string): Promise<void> {
81
+ const pending = this.pending.get(targetId);
82
+ if (!pending) return;
83
+ this.pending.delete(targetId);
84
+
85
+ if (pending.timer) clearTimeout(pending.timer);
86
+ const merged = pending.texts.join(this.separator);
87
+
88
+ try {
89
+ await this.flush(targetId, merged);
90
+ } finally {
91
+ pending.resolve();
92
+ }
93
+ }
94
+
95
+ /**
96
+ * 强制刷新所有 pending
97
+ */
98
+ async flushAll(): Promise<void> {
99
+ const keys = [...this.pending.keys()];
100
+ await Promise.all(keys.map((k) => this.doFlush(k)));
101
+ }
102
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * 出站 Deliver Pipeline — 处理 AI 回复的富媒体分发
3
+ *
4
+ * 框架通过 ReplyPayload 结构化字段投递回复:
5
+ * - text: 纯文本
6
+ * - mediaUrl: 单个媒体 URL
7
+ * - mediaUrls: 多个媒体 URL
8
+ * - audioAsVoice: 语音气泡(TTS)
9
+ *
10
+ * Pipeline 按顺序处理:
11
+ * 1. 语音意图 — audioAsVoice (TTS → SILK → 发送)
12
+ * 2. 媒体发送 — mediaUrl / mediaUrls(图片/文件/视频)
13
+ * 3. 纯文本回复 — 走 debounce 合并
14
+ */
15
+ import { sendMedia } from './media-send.js';
16
+ import type { MediaKind, SendResult } from './outbound-service.js';
17
+ import type { DeliverDebouncer } from './debounce.js';
18
+ import type { PluginLogger } from '../utils/plugin-logger.js';
19
+ import { isPathInAllowedRoots } from './local-file-router.js';
20
+ import * as path from 'node:path';
21
+ import * as os from 'node:os';
22
+
23
+ // ── 类型 ──
24
+
25
+ export interface DeliverPayload {
26
+ text?: string;
27
+ mediaUrl?: string;
28
+ mediaUrls?: string[];
29
+ audioAsVoice?: boolean;
30
+ }
31
+
32
+ export interface DeliverInfo {
33
+ kind: string;
34
+ }
35
+
36
+ export interface DeliverContext {
37
+ qualifiedTarget: string;
38
+ accountId: string;
39
+ replyToId: string;
40
+ /** 会话类型(用于判断语音是否可用) */
41
+ chatScope?: 'direct' | 'group';
42
+ /** 发送文本 */
43
+ sendText: (to: string, text: string) => Promise<SendResult>;
44
+ /** 发送媒体 */
45
+ sendMedia: (to: string, source: string, opts?: { text?: string; mediaKind?: MediaKind }) => Promise<SendResult>;
46
+ /** TTS 文字转语音(返回本地音频路径,失败返回 null) */
47
+ textToSpeech?: (params: { text: string; cfg?: unknown; channel?: string; accountId?: string }) => Promise<{ audioPath: string | null; error?: string }>;
48
+ /** 音频文件转 SILK Base64(QQ 语音格式) */
49
+ audioFileToSilkBase64?: (audioPath: string) => Promise<string | null>;
50
+ /** 出站文本合并防抖(可选,启用时纯文本走 debounce) */
51
+ debouncer?: DeliverDebouncer;
52
+ /** 运行时配置 */
53
+ cfg?: unknown;
54
+ /** 日志 */
55
+ log?: PluginLogger;
56
+ /** Agent ID(用于解析相对文件路径的工作区) */
57
+ agentId?: string;
58
+ }
59
+
60
+ /**
61
+ * 解析 ReplyPayload 中的媒体 URL 列表(优先 mediaUrls,fallback 单个 mediaUrl)
62
+ */
63
+ function resolveMediaUrls(payload: DeliverPayload): string[] {
64
+ if (payload.mediaUrls?.length) {
65
+ return payload.mediaUrls.filter(Boolean);
66
+ }
67
+ if (payload.mediaUrl) {
68
+ return [payload.mediaUrl];
69
+ }
70
+ // attachments[].path → 本地文件路径(findLocalPath() 展开可能相对路径)
71
+ return resolveAttachmentPaths(payload);
72
+ }
73
+
74
+ function resolveAttachmentPaths(payload: DeliverPayload): string[] {
75
+ const attachments = (payload as any).attachments as Array<{ path?: string; url?: string }> | undefined;
76
+ if (!attachments?.length) return [];
77
+ return attachments
78
+ .map((a) => a.url ?? a.path)
79
+ .filter((p): p is string => typeof p === 'string' && p.length > 0);
80
+ }
81
+
82
+ /**
83
+ * 出站 Deliver Pipeline
84
+ *
85
+ * 按顺序处理:
86
+ * 1. 语音意图 → TTS
87
+ * 2. 媒体发送 → mediaUrl / mediaUrls
88
+ * 3. 纯文本回复 → debounce 合并
89
+ */
90
+ export async function deliverReply(
91
+ payload: DeliverPayload,
92
+ _info: DeliverInfo | undefined,
93
+ ctx: DeliverContext,
94
+ ): Promise<void> {
95
+ const text = payload.text?.trim() ?? '';
96
+ const mediaUrls = resolveMediaUrls(payload);
97
+ const hasMedia = mediaUrls.length > 0;
98
+
99
+ ctx.log?.debug(`[text] textLen=${text.length} mediaCount=${mediaUrls.length}`);
100
+
101
+ // ── Layer 1: 语音意图 ──
102
+ if (payload.audioAsVoice) {
103
+ if (ctx.textToSpeech && text) {
104
+ const handled = await handleVoiceIntent(text, ctx);
105
+ if (handled) {
106
+ return; // 语音已发送,不再重复发送媒体或文本
107
+ }
108
+ }
109
+ // TTS 不可用或合成失败 → 降级为纯文本 + 媒体
110
+ }
111
+
112
+ // ── Layer 2: 媒体发送 ──
113
+ if (hasMedia) {
114
+ await sendMediaUrls(ctx, mediaUrls);
115
+ // 附带文本一并发送
116
+ if (text) {
117
+ const result = await ctx.sendText(ctx.qualifiedTarget, text);
118
+ if (result.error) {
119
+ ctx.log?.error(`[media] sendText failed: ${result.error}`);
120
+ }
121
+ }
122
+ return;
123
+ }
124
+
125
+ // ── Layer 3: 纯文本回复 ──
126
+ if (!text) return;
127
+
128
+ if (ctx.debouncer) {
129
+ await ctx.debouncer.enqueue(ctx.qualifiedTarget, text);
130
+ } else {
131
+ const result = await ctx.sendText(ctx.qualifiedTarget, text);
132
+ if (result.error) {
133
+ ctx.log?.error(`[text] sendText failed: ${result.error}`);
134
+ }
135
+ }
136
+ }
137
+
138
+ // ── 媒体批量发送 ──
139
+
140
+ async function sendMediaUrls(ctx: DeliverContext, urls: string[]): Promise<void> {
141
+ const failedUrls: string[] = [];
142
+ for (const url of urls) {
143
+ const result = await sendMedia({
144
+ to: ctx.qualifiedTarget,
145
+ source: url,
146
+ replyToId: ctx.replyToId,
147
+ accountId: ctx.accountId,
148
+ log: ctx.log,
149
+ agentId: ctx.agentId,
150
+ });
151
+ if (result.error) {
152
+ ctx.log?.error(`[media] ${result.error}`);
153
+ failedUrls.push(url);
154
+ }
155
+ }
156
+
157
+ // 媒体发送失败时通知用户
158
+ if (failedUrls.length > 0) {
159
+ const count = failedUrls.length;
160
+ const total = urls.length;
161
+ const failMsg = count === total
162
+ ? `⚠️ 媒体发送失败(${count} 个),请重试`
163
+ : `⚠️ ${count}/${total} 个媒体发送失败`;
164
+ const textResult = await ctx.sendText(ctx.qualifiedTarget, failMsg);
165
+ if (textResult.error) {
166
+ ctx.log?.error(`[media] failed to send failure notification: ${textResult.error}`);
167
+ }
168
+ }
169
+ }
170
+
171
+ // ── 语音意图处理 ──
172
+
173
+ async function handleVoiceIntent(text: string, ctx: DeliverContext): Promise<boolean> {
174
+ if (!text || !ctx.textToSpeech) return false;
175
+
176
+ // 仅 c2c/group 支持语音消息;其他场景 fallback 发文本
177
+ if (ctx.chatScope && ctx.chatScope !== 'direct' && ctx.chatScope !== 'group') {
178
+ return false;
179
+ }
180
+
181
+ try {
182
+ const ttsResult = await ctx.textToSpeech({
183
+ text,
184
+ cfg: ctx.cfg,
185
+ channel: 'qqbot',
186
+ accountId: ctx.accountId,
187
+ });
188
+
189
+ if (!ttsResult.audioPath) {
190
+ ctx.log?.error(`[tts] TTS failed: ${ttsResult.error ?? 'no audio path returned'}`);
191
+ return false;
192
+ }
193
+
194
+ // TTS 路径安全校验:确保音频文件在允许的临时目录内
195
+ if (!isTtsPathSafe(ttsResult.audioPath)) {
196
+ ctx.log?.error(`[tts] TTS audio path blocked: ${ttsResult.audioPath}`);
197
+ return false;
198
+ }
199
+
200
+ // 优先走 SILK base64
201
+ if (ctx.audioFileToSilkBase64) {
202
+ const silkBase64 = await ctx.audioFileToSilkBase64(ttsResult.audioPath);
203
+ if (silkBase64) {
204
+ const result = await ctx.sendMedia(ctx.qualifiedTarget, silkBase64, { mediaKind: 'voice' });
205
+ if (result.error) ctx.log?.error(`[tts] sendVoice(base64) failed: ${result.error}`);
206
+ return !result.error;
207
+ }
208
+ ctx.log?.error(`[tts] SILK conversion failed, trying localPath fallback`);
209
+ }
210
+
211
+ // Fallback: 直接发本地路径(SDK 内部处理格式转换)
212
+ const result = await ctx.sendMedia(ctx.qualifiedTarget, ttsResult.audioPath, { mediaKind: 'voice' });
213
+ if (result.error) {
214
+ ctx.log?.error(`[tts] sendVoice(localPath) failed: ${result.error}`);
215
+ return false;
216
+ }
217
+ return true;
218
+ } catch (err) {
219
+ ctx.log?.error(`[tts] TTS/voice send failed: ${err instanceof Error ? err.message : String(err)}`);
220
+ return false;
221
+ }
222
+ }
223
+
224
+ // ── TTS 路径安全 ──
225
+
226
+ const TTS_ALLOWED_ROOTS = [
227
+ path.join(os.homedir(), '.openclaw'),
228
+ os.tmpdir(),
229
+ '/tmp',
230
+ ];
231
+
232
+ function isTtsPathSafe(audioPath: string): boolean {
233
+ if (!path.isAbsolute(audioPath)) return false;
234
+ return isPathInAllowedRoots(audioPath, TTS_ALLOWED_ROOTS);
235
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Markdown 图片尺寸探测
3
+ *
4
+ * 发送前探测图片宽高,格式化为 `![img #WxH](url)` 格式。
5
+ * QQ 客户端 Markdown 渲染时需要这个尺寸提示来优化图片显示比例。
6
+ */
7
+
8
+ import { validateRemoteUrl } from '../utils/ssrf-guard.js';
9
+
10
+ /** 图片尺寸结果 */
11
+ export interface ImageSize {
12
+ width: number;
13
+ height: number;
14
+ }
15
+
16
+ /**
17
+ * 通过 HTTP Range 请求前 N 字节解析图片尺寸
18
+ *
19
+ * 支持 PNG / JPEG / GIF / WebP 格式的快速头部探测(不下载完整图片)。
20
+ * 内置 SSRF 防护:fetch 前先校验 URL 安全性。
21
+ */
22
+ export async function getImageSize(url: string, timeoutMs = 5000): Promise<ImageSize | null> {
23
+ try {
24
+ // SSRF 防护:禁止访问内网 / 云元数据端点
25
+ await validateRemoteUrl(url);
26
+
27
+ const controller = new AbortController();
28
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
29
+
30
+ const resp = await fetch(url, {
31
+ signal: controller.signal,
32
+ headers: { Range: 'bytes=0-32767' }, // 请求前 32KB 足够解析头
33
+ });
34
+ clearTimeout(timer);
35
+
36
+ if (!resp.ok && resp.status !== 206) return null;
37
+
38
+ const buffer = Buffer.from(await resp.arrayBuffer());
39
+ return parseImageDimensions(buffer);
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * 从 buffer 头部解析图片尺寸
47
+ */
48
+ function parseImageDimensions(buffer: Buffer): ImageSize | null {
49
+ if (buffer.length < 8) return null;
50
+
51
+ // PNG: 前 8 字节签名 + IHDR chunk (offset 16: width BE uint32, offset 20: height BE uint32)
52
+ if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
53
+ if (buffer.length >= 24) {
54
+ return {
55
+ width: buffer.readUInt32BE(16),
56
+ height: buffer.readUInt32BE(20),
57
+ };
58
+ }
59
+ }
60
+
61
+ // JPEG: 搜索 SOFn marker (0xFF 0xC0-0xCF, 除 0xC4/0xC8/0xCC)
62
+ if (buffer[0] === 0xff && buffer[1] === 0xd8) {
63
+ let offset = 2;
64
+ while (offset < buffer.length - 9) {
65
+ if (buffer[offset] !== 0xff) { offset++; continue; }
66
+ const marker = buffer[offset + 1];
67
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
68
+ return {
69
+ height: buffer.readUInt16BE(offset + 5),
70
+ width: buffer.readUInt16BE(offset + 7),
71
+ };
72
+ }
73
+ const segLen = buffer.readUInt16BE(offset + 2);
74
+ offset += 2 + segLen;
75
+ }
76
+ }
77
+
78
+ // GIF: offset 6: width LE uint16, offset 8: height LE uint16
79
+ if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) {
80
+ if (buffer.length >= 10) {
81
+ return {
82
+ width: buffer.readUInt16LE(6),
83
+ height: buffer.readUInt16LE(8),
84
+ };
85
+ }
86
+ }
87
+
88
+ // WebP: RIFF header + "WEBP" + VP8 chunks
89
+ if (buffer.length >= 30 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') {
90
+ const chunk = buffer.toString('ascii', 12, 16);
91
+ if (chunk === 'VP8 ' && buffer.length >= 30) {
92
+ // Lossy VP8: offset 26-27 width (LE) & offset 28-29 height (LE), masked lower 14 bits
93
+ return {
94
+ width: buffer.readUInt16LE(26) & 0x3fff,
95
+ height: buffer.readUInt16LE(28) & 0x3fff,
96
+ };
97
+ }
98
+ if (chunk === 'VP8L' && buffer.length >= 25) {
99
+ // Lossless VP8L: 5 bytes at offset 21, first 14 bits = width-1, next 14 bits = height-1
100
+ const bits = buffer.readUInt32LE(21);
101
+ return {
102
+ width: (bits & 0x3fff) + 1,
103
+ height: ((bits >> 14) & 0x3fff) + 1,
104
+ };
105
+ }
106
+ }
107
+
108
+ return null;
109
+ }
110
+
111
+ /**
112
+ * 格式化 QQ Markdown 图片标签(带尺寸提示)
113
+ *
114
+ * @example
115
+ * formatQQMarkdownImage('https://x/img.png', { width: 800, height: 600 })
116
+ * // → '![img #800x600](https://x/img.png)'
117
+ */
118
+ export function formatQQMarkdownImage(url: string, size?: ImageSize | null): string {
119
+ if (size) {
120
+ return `![img #${size.width}x${size.height}](${url})`;
121
+ }
122
+ return `![img](${url})`;
123
+ }
@@ -0,0 +1,3 @@
1
+ export { OutboundService, sendText, sendMedia, sendVoice, sendVideo } from './outbound-service.js';
2
+ export { parseTarget, normalizeTarget, isQQBotTarget } from './target.js';
3
+ export type { SendResult, MediaKind } from './outbound-service.js';