@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
@@ -1,486 +0,0 @@
1
- /**
2
- * 出站消息投递模块
3
- *
4
- * 从 gateway deliver 回调中提取的两大发送管线:
5
- * 1. parseAndSendMediaTags — 解析 <qqimg/qqvoice/qqvideo/qqfile/qqmedia> 标签并按顺序发送
6
- * 2. sendPlainReply — 处理不含媒体标签的普通回复(markdown 图片/纯文本+图片)
7
- */
8
-
9
- import type { ResolvedQQBotAccount } from "./types.js";
10
- import {
11
- sendC2CMessage,
12
- sendGroupMessage,
13
- sendChannelMessage,
14
- sendC2CImageMessage,
15
- sendGroupImageMessage,
16
- } from "./api.js";
17
- import {
18
- sendPhoto,
19
- sendMedia as sendMediaAuto,
20
- DEFAULT_MEDIA_SEND_ERROR,
21
- resolveUserFacingMediaError,
22
- type MediaTargetContext,
23
- } from "./outbound.js";
24
- import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
25
- import { getQQBotRuntime } from "./runtime.js";
26
- import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
27
- import { parseMediaTagsToSendQueue, executeSendQueue, type MediaSendContext } from "./utils/media-send.js";
28
- import { isLocalPath as isLocalFilePath } from "./utils/platform.js";
29
- import { filterInternalMarkers } from "./utils/text-parsing.js";
30
-
31
- // ============ 类型定义 ============
32
-
33
- export interface DeliverEventContext {
34
- type: "c2c" | "guild" | "dm" | "group";
35
- senderId: string;
36
- messageId: string;
37
- channelId?: string;
38
- groupOpenid?: string;
39
- msgIdx?: string;
40
- }
41
-
42
- export interface DeliverAccountContext {
43
- account: ResolvedQQBotAccount;
44
- qualifiedTarget: string;
45
- log?: {
46
- info: (msg: string) => void;
47
- error: (msg: string) => void;
48
- debug?: (msg: string) => void;
49
- };
50
- }
51
-
52
- /** token 重试包装 */
53
- export type SendWithRetryFn = <T>(sendFn: (token: string) => Promise<T>) => Promise<T>;
54
-
55
- /** 一次性消费引用 ref */
56
- export type ConsumeQuoteRefFn = () => string | undefined;
57
-
58
- // ============ 1. 媒体标签解析 + 发送 ============
59
-
60
- /**
61
- * 解析回复文本中的媒体标签并按顺序发送。
62
- *
63
- * @returns true 如果检测到媒体标签并已处理;false 表示无媒体标签,调用方继续走普通文本管线
64
- */
65
- export async function parseAndSendMediaTags(
66
- replyText: string,
67
- event: DeliverEventContext,
68
- actx: DeliverAccountContext,
69
- sendWithRetry: SendWithRetryFn,
70
- consumeQuoteRef: ConsumeQuoteRefFn,
71
- ): Promise<{ handled: boolean; normalizedText: string }> {
72
- const { account, log } = actx;
73
- const prefix = `[qqbot:${account.accountId}]`;
74
-
75
- // 使用 media-send.ts 的统一解析器(内含 normalizeMediaTags + 路径编码修复)
76
- const { hasMediaTags: hasMedia, sendQueue } = parseMediaTagsToSendQueue(replyText, log);
77
-
78
- if (!hasMedia || sendQueue.length === 0) {
79
- return { handled: false, normalizedText: replyText };
80
- }
81
-
82
- log?.info(`${prefix} Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
83
-
84
- // 构建统一的媒体发送上下文
85
- const mediaTarget: MediaTargetContext = {
86
- targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
87
- targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
88
- account,
89
- replyToId: event.messageId,
90
- logPrefix: prefix,
91
- };
92
-
93
- const mediaSendCtx: MediaSendContext = {
94
- mediaTarget,
95
- qualifiedTarget: actx.qualifiedTarget,
96
- account,
97
- replyToId: event.messageId,
98
- log,
99
- };
100
-
101
- // 使用 media-send.ts 的统一执行器
102
- await executeSendQueue(sendQueue, mediaSendCtx, {
103
- onSendText: async (textContent) => {
104
- await sendTextChunks(filterInternalMarkers(textContent), event, actx, sendWithRetry, consumeQuoteRef);
105
- },
106
- });
107
-
108
- return { handled: true, normalizedText: replyText };
109
- }
110
-
111
- // ============ 2. 非结构化消息发送(普通文本 + 图片) ============
112
-
113
- export interface PlainReplyPayload {
114
- text?: string;
115
- mediaUrls?: string[];
116
- mediaUrl?: string;
117
- }
118
-
119
- /**
120
- * 发送不含媒体标签的普通回复。
121
- * 处理 markdown 图片嵌入、Base64 富媒体、纯文本分块、本地媒体自动路由。
122
- */
123
- export async function sendPlainReply(
124
- payload: PlainReplyPayload,
125
- replyText: string,
126
- event: DeliverEventContext,
127
- actx: DeliverAccountContext,
128
- sendWithRetry: SendWithRetryFn,
129
- consumeQuoteRef: ConsumeQuoteRefFn,
130
- toolMediaUrls: string[],
131
- ): Promise<void> {
132
- const { account, qualifiedTarget, log } = actx;
133
- const prefix = `[qqbot:${account.accountId}]`;
134
-
135
- // 预去重:把 payload 自带的媒体 URL 从 toolMediaUrls 中移除,
136
- // 防止同一个文件既被 payload.mediaUrl/mediaUrls 发送,又被 toolMediaUrls 重复发送
137
- if (toolMediaUrls.length > 0) {
138
- const payloadUrls = new Set<string>();
139
- if (payload.mediaUrl) payloadUrls.add(payload.mediaUrl);
140
- if (payload.mediaUrls) for (const u of payload.mediaUrls) payloadUrls.add(u);
141
- if (payloadUrls.size > 0) {
142
- const before = toolMediaUrls.length;
143
- const filtered = toolMediaUrls.filter(url => !payloadUrls.has(url));
144
- if (filtered.length < before) {
145
- log?.info(`${prefix} Pre-dedup: removed ${before - filtered.length} payload media URL(s) from toolMediaUrls`);
146
- toolMediaUrls.length = 0;
147
- toolMediaUrls.push(...filtered);
148
- }
149
- }
150
- }
151
-
152
- const collectedImageUrls: string[] = [];
153
- const localMediaToSend: string[] = [];
154
-
155
- const collectImageUrl = (url: string | undefined | null): boolean => {
156
- if (!url) return false;
157
- const isHttpUrl = url.startsWith("http://") || url.startsWith("https://");
158
- const isDataUrl = url.startsWith("data:image/");
159
- if (isHttpUrl || isDataUrl) {
160
- if (!collectedImageUrls.includes(url)) {
161
- collectedImageUrls.push(url);
162
- log?.info(`${prefix} Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : url.slice(0, 80) + "..."}`);
163
- }
164
- return true;
165
- }
166
- if (isLocalFilePath(url)) {
167
- if (!localMediaToSend.includes(url)) {
168
- localMediaToSend.push(url);
169
- log?.info(`${prefix} Collected local media for auto-routing: ${url}`);
170
- }
171
- return true;
172
- }
173
- return false;
174
- };
175
-
176
- if (payload.mediaUrls?.length) {
177
- for (const url of payload.mediaUrls) collectImageUrl(url);
178
- }
179
- if (payload.mediaUrl) collectImageUrl(payload.mediaUrl);
180
-
181
- // 提取 markdown 图片
182
- const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi;
183
- const mdMatches = [...replyText.matchAll(mdImageRegex)];
184
- for (const m of mdMatches) {
185
- const url = m[2]?.trim();
186
- if (url && !collectedImageUrls.includes(url)) {
187
- if (url.startsWith("http://") || url.startsWith("https://")) {
188
- collectedImageUrls.push(url);
189
- log?.info(`${prefix} Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
190
- } else if (isLocalFilePath(url)) {
191
- if (!localMediaToSend.includes(url)) {
192
- localMediaToSend.push(url);
193
- log?.info(`${prefix} Collected local media from markdown for auto-routing: ${url}`);
194
- }
195
- }
196
- }
197
- }
198
-
199
- // 提取裸 URL 图片
200
- const bareUrlRegex = /(?<![(\["'])(https?:\/\/[^\s)"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi;
201
- const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)];
202
- for (const m of bareUrlMatches) {
203
- const url = m[1];
204
- if (url && !collectedImageUrls.includes(url)) {
205
- collectedImageUrls.push(url);
206
- log?.info(`${prefix} Extracted bare image URL: ${url.slice(0, 80)}...`);
207
- }
208
- }
209
-
210
- const useMarkdown = account.markdownSupport === true;
211
- log?.info(`${prefix} Markdown mode: ${useMarkdown}, images: ${collectedImageUrls.length}`);
212
-
213
- let textWithoutImages = filterInternalMarkers(replyText);
214
-
215
- if (useMarkdown) {
216
- await sendMarkdownReply(textWithoutImages, collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef);
217
- } else {
218
- await sendPlainTextReply(textWithoutImages, collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef);
219
- }
220
-
221
- // 发送本地媒体(由 payload.mediaUrl 或 markdown 本地路径触发)
222
- if (localMediaToSend.length > 0) {
223
- log?.info(`${prefix} Sending ${localMediaToSend.length} local media via sendMedia auto-routing`);
224
- for (const mediaPath of localMediaToSend) {
225
- try {
226
- const result = await sendMediaAuto({
227
- to: qualifiedTarget, text: "", mediaUrl: mediaPath,
228
- accountId: account.accountId, replyToId: event.messageId, account,
229
- });
230
- if (result.error) {
231
- log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`);
232
- await sendTextChunks(resolveUserFacingMediaError(result), event, actx, sendWithRetry, consumeQuoteRef);
233
- } else {
234
- log?.info(`${prefix} Sent local media: ${mediaPath}`);
235
- }
236
- } catch (err) {
237
- log?.error(`${prefix} sendMedia(auto) failed for ${mediaPath}: ${err}`);
238
- await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
239
- }
240
- }
241
- }
242
-
243
- // 转发 tool 阶段收集的媒体(去重:跳过已在 localMediaToSend 或 collectedImageUrls 中发送过的路径)
244
- if (toolMediaUrls.length > 0) {
245
- const alreadySent = new Set([...localMediaToSend, ...collectedImageUrls]);
246
- const dedupedToolMedia = toolMediaUrls.filter(url => !alreadySent.has(url));
247
- if (dedupedToolMedia.length < toolMediaUrls.length) {
248
- log?.info(`${prefix} Deduped tool media: ${toolMediaUrls.length} → ${dedupedToolMedia.length} (skipped ${toolMediaUrls.length - dedupedToolMedia.length} already sent via localMedia/collectedImages)`);
249
- }
250
- if (dedupedToolMedia.length > 0) {
251
- log?.info(`${prefix} Forwarding ${dedupedToolMedia.length} tool-collected media URL(s) after block deliver`);
252
- for (const mediaUrl of dedupedToolMedia) {
253
- try {
254
- const result = await sendMediaAuto({
255
- to: qualifiedTarget, text: "", mediaUrl,
256
- accountId: account.accountId, replyToId: event.messageId, account,
257
- });
258
- if (result.error) {
259
- log?.error(`${prefix} Tool media forward error: ${result.error}`);
260
- await sendTextChunks(resolveUserFacingMediaError(result), event, actx, sendWithRetry, consumeQuoteRef);
261
- } else {
262
- log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
263
- }
264
- } catch (err) {
265
- log?.error(`${prefix} Tool media forward failed: ${err}`);
266
- await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
267
- }
268
- }
269
- }
270
- toolMediaUrls.length = 0;
271
- }
272
- }
273
-
274
- // ============ 内部辅助函数 ============
275
-
276
-
277
- /** 发送文本分块(共用逻辑) */
278
- async function sendTextChunks(
279
- text: string,
280
- event: DeliverEventContext,
281
- actx: DeliverAccountContext,
282
- sendWithRetry: SendWithRetryFn,
283
- consumeQuoteRef: ConsumeQuoteRefFn,
284
- ): Promise<void> {
285
- const { account, log } = actx;
286
- const prefix = `[qqbot:${account.accountId}]`;
287
- const chunks = getQQBotRuntime().channel.text.chunkMarkdownText(text, TEXT_CHUNK_LIMIT);
288
- for (const chunk of chunks) {
289
- try {
290
- await sendWithRetry(async (token) => {
291
- const ref = consumeQuoteRef();
292
- if (event.type === "c2c") {
293
- return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
294
- } else if (event.type === "group" && event.groupOpenid) {
295
- return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
296
- } else if (event.channelId) {
297
- return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
298
- }
299
- });
300
- log?.info(`${prefix} Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`);
301
- } catch (err) {
302
- log?.error(`${prefix} Failed to send text chunk: ${err}`);
303
- }
304
- }
305
- }
306
-
307
- /** Markdown 模式发送 */
308
- async function sendMarkdownReply(
309
- textWithoutImages: string,
310
- imageUrls: string[],
311
- mdMatches: RegExpMatchArray[],
312
- bareUrlMatches: RegExpMatchArray[],
313
- event: DeliverEventContext,
314
- actx: DeliverAccountContext,
315
- sendWithRetry: SendWithRetryFn,
316
- consumeQuoteRef: ConsumeQuoteRefFn,
317
- ): Promise<void> {
318
- const { account, log } = actx;
319
- const prefix = `[qqbot:${account.accountId}]`;
320
-
321
- // 分离图片:公网 URL vs Base64
322
- const httpImageUrls: string[] = [];
323
- const base64ImageUrls: string[] = [];
324
- for (const url of imageUrls) {
325
- if (url.startsWith("data:image/")) base64ImageUrls.push(url);
326
- else if (url.startsWith("http://") || url.startsWith("https://")) httpImageUrls.push(url);
327
- }
328
- log?.info(`${prefix} Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`);
329
-
330
- // 发送 Base64 图片
331
- if (base64ImageUrls.length > 0) {
332
- log?.info(`${prefix} Sending ${base64ImageUrls.length} image(s) via Rich Media API...`);
333
- for (const imageUrl of base64ImageUrls) {
334
- try {
335
- await sendWithRetry(async (token) => {
336
- if (event.type === "c2c") {
337
- await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
338
- } else if (event.type === "group" && event.groupOpenid) {
339
- await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
340
- } else if (event.channelId) {
341
- log?.info(`${prefix} Channel does not support rich media, skipping Base64 image`);
342
- }
343
- });
344
- log?.info(`${prefix} Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`);
345
- } catch (imgErr) {
346
- log?.error(`${prefix} Failed to send Base64 image via Rich Media API: ${imgErr}`);
347
- }
348
- }
349
- }
350
-
351
- // 处理公网 URL 图片
352
- const existingMdUrls = new Set(mdMatches.map((m) => m[2]));
353
- const imagesToAppend: string[] = [];
354
-
355
- for (const url of httpImageUrls) {
356
- if (!existingMdUrls.has(url)) {
357
- try {
358
- const size = await getImageSize(url);
359
- imagesToAppend.push(formatQQBotMarkdownImage(url, size));
360
- log?.info(`${prefix} Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${url.slice(0, 60)}...`);
361
- } catch (err) {
362
- log?.info(`${prefix} Failed to get image size, using default: ${err}`);
363
- imagesToAppend.push(formatQQBotMarkdownImage(url, null));
364
- }
365
- }
366
- }
367
-
368
- // 补充已有 markdown 图片的尺寸信息
369
- let result = textWithoutImages;
370
- for (const m of mdMatches) {
371
- const fullMatch = m[0];
372
- const imgUrl = m[2];
373
- const isHttpUrl = imgUrl.startsWith("http://") || imgUrl.startsWith("https://");
374
- if (isHttpUrl && !hasQQBotImageSize(fullMatch)) {
375
- try {
376
- const size = await getImageSize(imgUrl);
377
- result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size));
378
- log?.info(`${prefix} Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${imgUrl.slice(0, 60)}...`);
379
- } catch (err) {
380
- log?.info(`${prefix} Failed to get image size for existing md, using default: ${err}`);
381
- result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, null));
382
- }
383
- }
384
- }
385
-
386
- // 移除裸 URL 图片
387
- for (const m of bareUrlMatches) {
388
- result = result.replace(m[0], "").trim();
389
- }
390
-
391
- // 追加图片
392
- if (imagesToAppend.length > 0) {
393
- result = result.trim();
394
- result = result ? result + "\n\n" + imagesToAppend.join("\n") : imagesToAppend.join("\n");
395
- }
396
-
397
- // 发送 markdown 文本
398
- if (result.trim()) {
399
- const mdChunks = chunkText(result, TEXT_CHUNK_LIMIT);
400
- for (const chunk of mdChunks) {
401
- try {
402
- await sendWithRetry(async (token) => {
403
- const ref = consumeQuoteRef();
404
- if (event.type === "c2c") {
405
- return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
406
- } else if (event.type === "group" && event.groupOpenid) {
407
- return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
408
- } else if (event.channelId) {
409
- return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
410
- }
411
- });
412
- log?.info(`${prefix} Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`);
413
- } catch (err) {
414
- log?.error(`${prefix} Failed to send markdown message chunk: ${err}`);
415
- }
416
- }
417
- }
418
- }
419
-
420
- /** 普通文本模式发送 */
421
- async function sendPlainTextReply(
422
- textWithoutImages: string,
423
- imageUrls: string[],
424
- mdMatches: RegExpMatchArray[],
425
- bareUrlMatches: RegExpMatchArray[],
426
- event: DeliverEventContext,
427
- actx: DeliverAccountContext,
428
- sendWithRetry: SendWithRetryFn,
429
- consumeQuoteRef: ConsumeQuoteRefFn,
430
- ): Promise<void> {
431
- const { account, log } = actx;
432
- const prefix = `[qqbot:${account.accountId}]`;
433
-
434
- const imgMediaTarget: MediaTargetContext = {
435
- targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
436
- targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
437
- account,
438
- replyToId: event.messageId,
439
- logPrefix: prefix,
440
- };
441
-
442
- let result = textWithoutImages;
443
- for (const m of mdMatches) result = result.replace(m[0], "").trim();
444
- for (const m of bareUrlMatches) result = result.replace(m[0], "").trim();
445
-
446
- // 群聊 URL 点号过滤
447
- if (result && event.type !== "c2c") {
448
- result = result.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
449
- }
450
-
451
- try {
452
- for (const imageUrl of imageUrls) {
453
- try {
454
- const imgResult = await sendPhoto(imgMediaTarget, imageUrl);
455
- if (imgResult.error) {
456
- log?.error(`${prefix} Failed to send image: ${imgResult.error}`);
457
- await sendTextChunks(resolveUserFacingMediaError(imgResult), event, actx, sendWithRetry, consumeQuoteRef);
458
- } else {
459
- log?.info(`${prefix} Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
460
- }
461
- } catch (imgErr) {
462
- log?.error(`${prefix} Failed to send image: ${imgErr}`);
463
- await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
464
- }
465
- }
466
-
467
- if (result.trim()) {
468
- const plainChunks = chunkText(result, TEXT_CHUNK_LIMIT);
469
- for (const chunk of plainChunks) {
470
- await sendWithRetry(async (token) => {
471
- const ref = consumeQuoteRef();
472
- if (event.type === "c2c") {
473
- return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
474
- } else if (event.type === "group" && event.groupOpenid) {
475
- return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
476
- } else if (event.channelId) {
477
- return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
478
- }
479
- });
480
- log?.info(`${prefix} Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`);
481
- }
482
- }
483
- } catch (err) {
484
- log?.error(`${prefix} Send failed: ${err}`);
485
- }
486
- }