@openclaw/qqbot 2026.5.2 → 2026.5.3-beta.2

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 (203) hide show
  1. package/dist/api.js +646 -0
  2. package/dist/approval-cg0SVahb.js +94 -0
  3. package/dist/channel-N6Y_Rcjp.js +551 -0
  4. package/dist/channel-plugin-api.js +2 -0
  5. package/dist/channel.setup-ByCRAe5H.js +25 -0
  6. package/dist/config-D6545NkC.js +94 -0
  7. package/dist/config-schema-DFcjQw73.js +309 -0
  8. package/dist/exec-approvals-COUsM6wZ.js +138 -0
  9. package/dist/gateway-CmSUJKSt.js +6726 -0
  10. package/dist/handler-runtime-Bqm6N0WG.js +120 -0
  11. package/dist/index.js +29 -0
  12. package/dist/narrowing-BoieBTIU.js +25 -0
  13. package/dist/outbound-BJfhwrPg.js +1357 -0
  14. package/dist/request-context-DXtpwWui.js +921 -0
  15. package/dist/resolve-D_06fV6-.js +284 -0
  16. package/dist/runtime-BJAS3eXW.js +18 -0
  17. package/dist/runtime-api.js +2 -0
  18. package/dist/secret-contract-api.js +56 -0
  19. package/dist/sender-p-B14eLG.js +1987 -0
  20. package/dist/setup-entry.js +15 -0
  21. package/dist/setup-plugin-api.js +2 -0
  22. package/dist/string-normalize-Ci6NM5DE.js +98 -0
  23. package/dist/target-parser-Y0prnrXD.js +245 -0
  24. package/package.json +15 -6
  25. package/api.ts +0 -56
  26. package/channel-plugin-api.ts +0 -1
  27. package/index.ts +0 -29
  28. package/runtime-api.ts +0 -9
  29. package/setup-entry.ts +0 -9
  30. package/setup-plugin-api.ts +0 -3
  31. package/src/bridge/approval/capability.ts +0 -237
  32. package/src/bridge/approval/handler-runtime.ts +0 -204
  33. package/src/bridge/bootstrap.ts +0 -135
  34. package/src/bridge/channel-entry.ts +0 -18
  35. package/src/bridge/commands/framework-context-adapter.ts +0 -60
  36. package/src/bridge/commands/framework-registration.ts +0 -47
  37. package/src/bridge/commands/from-parser.test.ts +0 -86
  38. package/src/bridge/commands/from-parser.ts +0 -60
  39. package/src/bridge/commands/result-dispatcher.ts +0 -76
  40. package/src/bridge/config-shared.ts +0 -132
  41. package/src/bridge/config.ts +0 -111
  42. package/src/bridge/gateway.ts +0 -174
  43. package/src/bridge/logger.ts +0 -31
  44. package/src/bridge/narrowing.ts +0 -31
  45. package/src/bridge/plugin-version.test.ts +0 -146
  46. package/src/bridge/plugin-version.ts +0 -102
  47. package/src/bridge/runtime.ts +0 -25
  48. package/src/bridge/sdk-adapter.ts +0 -131
  49. package/src/bridge/setup/finalize.ts +0 -144
  50. package/src/bridge/setup/surface.ts +0 -34
  51. package/src/bridge/tools/channel.ts +0 -58
  52. package/src/bridge/tools/index.ts +0 -15
  53. package/src/bridge/tools/remind.test.ts +0 -124
  54. package/src/bridge/tools/remind.ts +0 -91
  55. package/src/channel.setup.ts +0 -33
  56. package/src/channel.ts +0 -288
  57. package/src/command-auth.test.ts +0 -62
  58. package/src/config-schema.ts +0 -84
  59. package/src/config.test.ts +0 -364
  60. package/src/engine/access/access-control.test.ts +0 -198
  61. package/src/engine/access/access-control.ts +0 -226
  62. package/src/engine/access/index.ts +0 -16
  63. package/src/engine/access/resolve-policy.test.ts +0 -59
  64. package/src/engine/access/resolve-policy.ts +0 -57
  65. package/src/engine/access/sender-match.test.ts +0 -60
  66. package/src/engine/access/sender-match.ts +0 -55
  67. package/src/engine/access/types.ts +0 -53
  68. package/src/engine/adapter/audio.port.ts +0 -27
  69. package/src/engine/adapter/commands.port.ts +0 -22
  70. package/src/engine/adapter/history.port.ts +0 -52
  71. package/src/engine/adapter/index.ts +0 -139
  72. package/src/engine/adapter/mention-gate.port.ts +0 -50
  73. package/src/engine/adapter/types.ts +0 -38
  74. package/src/engine/api/api-client.ts +0 -212
  75. package/src/engine/api/media-chunked.test.ts +0 -336
  76. package/src/engine/api/media-chunked.ts +0 -622
  77. package/src/engine/api/media.ts +0 -218
  78. package/src/engine/api/messages.ts +0 -293
  79. package/src/engine/api/retry.ts +0 -217
  80. package/src/engine/api/routes.ts +0 -95
  81. package/src/engine/api/token.ts +0 -271
  82. package/src/engine/approval/index.test.ts +0 -22
  83. package/src/engine/approval/index.ts +0 -224
  84. package/src/engine/commands/builtin/log-helpers.ts +0 -319
  85. package/src/engine/commands/builtin/register-all.ts +0 -17
  86. package/src/engine/commands/builtin/register-approve.ts +0 -201
  87. package/src/engine/commands/builtin/register-basic.ts +0 -95
  88. package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
  89. package/src/engine/commands/builtin/register-logs.ts +0 -20
  90. package/src/engine/commands/builtin/register-streaming.ts +0 -137
  91. package/src/engine/commands/builtin/state.ts +0 -31
  92. package/src/engine/commands/slash-command-auth.ts +0 -48
  93. package/src/engine/commands/slash-command-handler.ts +0 -146
  94. package/src/engine/commands/slash-commands-impl.test.ts +0 -8
  95. package/src/engine/commands/slash-commands-impl.ts +0 -61
  96. package/src/engine/commands/slash-commands.ts +0 -199
  97. package/src/engine/config/credential-backup.test.ts +0 -88
  98. package/src/engine/config/credential-backup.ts +0 -107
  99. package/src/engine/config/credentials.ts +0 -76
  100. package/src/engine/config/group.test.ts +0 -234
  101. package/src/engine/config/group.ts +0 -299
  102. package/src/engine/config/resolve.test.ts +0 -152
  103. package/src/engine/config/resolve.ts +0 -283
  104. package/src/engine/config/setup-logic.ts +0 -84
  105. package/src/engine/engine-import-boundary.test.ts +0 -73
  106. package/src/engine/gateway/codec.ts +0 -47
  107. package/src/engine/gateway/constants.ts +0 -117
  108. package/src/engine/gateway/event-dispatcher.ts +0 -177
  109. package/src/engine/gateway/gateway-connection.ts +0 -371
  110. package/src/engine/gateway/gateway.ts +0 -291
  111. package/src/engine/gateway/inbound-attachments.test.ts +0 -126
  112. package/src/engine/gateway/inbound-attachments.ts +0 -360
  113. package/src/engine/gateway/inbound-context.ts +0 -195
  114. package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
  115. package/src/engine/gateway/inbound-pipeline.ts +0 -235
  116. package/src/engine/gateway/interaction-handler.ts +0 -220
  117. package/src/engine/gateway/message-queue.test.ts +0 -282
  118. package/src/engine/gateway/message-queue.ts +0 -499
  119. package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
  120. package/src/engine/gateway/outbound-dispatch.ts +0 -575
  121. package/src/engine/gateway/reconnect.ts +0 -199
  122. package/src/engine/gateway/stages/access-stage.ts +0 -132
  123. package/src/engine/gateway/stages/assembly-stage.ts +0 -156
  124. package/src/engine/gateway/stages/content-stage.test.ts +0 -77
  125. package/src/engine/gateway/stages/content-stage.ts +0 -77
  126. package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
  127. package/src/engine/gateway/stages/envelope-stage.ts +0 -144
  128. package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
  129. package/src/engine/gateway/stages/index.ts +0 -18
  130. package/src/engine/gateway/stages/quote-stage.ts +0 -113
  131. package/src/engine/gateway/stages/refidx-stage.ts +0 -62
  132. package/src/engine/gateway/stages/stub-contexts.ts +0 -116
  133. package/src/engine/gateway/types.ts +0 -264
  134. package/src/engine/gateway/typing-keepalive.ts +0 -79
  135. package/src/engine/group/activation.test.ts +0 -114
  136. package/src/engine/group/activation.ts +0 -147
  137. package/src/engine/group/history.test.ts +0 -314
  138. package/src/engine/group/history.ts +0 -321
  139. package/src/engine/group/mention.test.ts +0 -141
  140. package/src/engine/group/mention.ts +0 -197
  141. package/src/engine/group/message-gating.test.ts +0 -188
  142. package/src/engine/group/message-gating.ts +0 -216
  143. package/src/engine/messaging/decode-media-path.ts +0 -82
  144. package/src/engine/messaging/media-source.ts +0 -215
  145. package/src/engine/messaging/media-type-detect.ts +0 -37
  146. package/src/engine/messaging/outbound-audio-port.ts +0 -38
  147. package/src/engine/messaging/outbound-deliver.ts +0 -810
  148. package/src/engine/messaging/outbound-media-send.ts +0 -702
  149. package/src/engine/messaging/outbound-reply.ts +0 -27
  150. package/src/engine/messaging/outbound-result-helpers.ts +0 -54
  151. package/src/engine/messaging/outbound-types.ts +0 -45
  152. package/src/engine/messaging/outbound.ts +0 -485
  153. package/src/engine/messaging/reply-dispatcher.ts +0 -597
  154. package/src/engine/messaging/reply-limiter.ts +0 -164
  155. package/src/engine/messaging/sender.ts +0 -729
  156. package/src/engine/messaging/streaming-c2c.ts +0 -1192
  157. package/src/engine/messaging/streaming-media-send.ts +0 -544
  158. package/src/engine/messaging/target-parser.ts +0 -104
  159. package/src/engine/ref/format-message-ref.ts +0 -142
  160. package/src/engine/ref/format-ref-entry.test.ts +0 -60
  161. package/src/engine/ref/format-ref-entry.ts +0 -27
  162. package/src/engine/ref/store.ts +0 -208
  163. package/src/engine/ref/types.ts +0 -27
  164. package/src/engine/session/known-users.ts +0 -137
  165. package/src/engine/session/session-store.ts +0 -204
  166. package/src/engine/tools/channel-api.ts +0 -244
  167. package/src/engine/tools/remind-logic.test.ts +0 -280
  168. package/src/engine/tools/remind-logic.ts +0 -377
  169. package/src/engine/types.ts +0 -313
  170. package/src/engine/utils/attachment-tags.test.ts +0 -186
  171. package/src/engine/utils/attachment-tags.ts +0 -174
  172. package/src/engine/utils/audio.test.ts +0 -250
  173. package/src/engine/utils/audio.ts +0 -585
  174. package/src/engine/utils/data-paths.ts +0 -38
  175. package/src/engine/utils/diagnostics.ts +0 -109
  176. package/src/engine/utils/file-utils.test.ts +0 -72
  177. package/src/engine/utils/file-utils.ts +0 -225
  178. package/src/engine/utils/format.test.ts +0 -68
  179. package/src/engine/utils/format.ts +0 -70
  180. package/src/engine/utils/image-size.test.ts +0 -158
  181. package/src/engine/utils/image-size.ts +0 -249
  182. package/src/engine/utils/log.test.ts +0 -28
  183. package/src/engine/utils/log.ts +0 -61
  184. package/src/engine/utils/media-tags.test.ts +0 -32
  185. package/src/engine/utils/media-tags.ts +0 -177
  186. package/src/engine/utils/payload.test.ts +0 -68
  187. package/src/engine/utils/payload.ts +0 -145
  188. package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
  189. package/src/engine/utils/platform.test.ts +0 -148
  190. package/src/engine/utils/platform.ts +0 -343
  191. package/src/engine/utils/request-context.ts +0 -60
  192. package/src/engine/utils/string-normalize.ts +0 -91
  193. package/src/engine/utils/stt.test.ts +0 -104
  194. package/src/engine/utils/stt.ts +0 -100
  195. package/src/engine/utils/text-parsing.test.ts +0 -29
  196. package/src/engine/utils/text-parsing.ts +0 -155
  197. package/src/engine/utils/upload-cache.ts +0 -96
  198. package/src/engine/utils/voice-text.ts +0 -15
  199. package/src/exec-approvals.ts +0 -218
  200. package/src/manifest-schema.test.ts +0 -56
  201. package/src/qqbot-test-support.ts +0 -29
  202. package/src/types.ts +0 -210
  203. package/tsconfig.json +0 -16
@@ -1,810 +0,0 @@
1
- /**
2
- * Outbound delivery helpers — core/ version.
3
- *
4
- * Uses the unified `sender.ts` business function layer for all text and
5
- * image sending. Media sends (photo/voice/video/file) are injected via
6
- * `DeliverDeps.mediaSender`.
7
- */
8
-
9
- import type { GatewayAccount } from "../types.js";
10
- import { formatErrorMessage } from "../utils/format.js";
11
- import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "../utils/image-size.js";
12
- import { normalizeMediaTags } from "../utils/media-tags.js";
13
- import { isLocalPath as isLocalFilePath } from "../utils/platform.js";
14
- import {
15
- normalizeLowercaseStringOrEmpty,
16
- normalizeOptionalString,
17
- } from "../utils/string-normalize.js";
18
- import { filterInternalMarkers } from "../utils/text-parsing.js";
19
- import { decodeMediaPath } from "./decode-media-path.js";
20
- import {
21
- sendText as senderSendText,
22
- sendMedia as senderSendMedia,
23
- withTokenRetry,
24
- buildDeliveryTarget,
25
- accountToCreds,
26
- } from "./sender.js";
27
-
28
- // ---- Injected dependency interfaces ----
29
-
30
- /** Media target context — describes where to send media. */
31
- interface MediaTargetContext {
32
- targetType: "c2c" | "group" | "channel" | "dm";
33
- targetId: string;
34
- account: GatewayAccount;
35
- replyToId?: string;
36
- }
37
-
38
- /** Media send result. */
39
- interface MediaSendResult {
40
- channel?: string;
41
- error?: string;
42
- messageId?: string;
43
- }
44
-
45
- /** Media sender interface — implemented by the upper-layer outbound.ts module. */
46
- interface MediaSender {
47
- sendPhoto(target: MediaTargetContext, imageUrl: string): Promise<MediaSendResult>;
48
- sendVoice(
49
- target: MediaTargetContext,
50
- voicePath: string,
51
- uploadFormats?: string[],
52
- transcodeEnabled?: boolean,
53
- ): Promise<MediaSendResult>;
54
- sendVideoMsg(target: MediaTargetContext, videoPath: string): Promise<MediaSendResult>;
55
- sendDocument(target: MediaTargetContext, filePath: string): Promise<MediaSendResult>;
56
- sendMedia(opts: {
57
- to: string;
58
- text: string;
59
- mediaUrl: string;
60
- accountId: string;
61
- replyToId: string;
62
- account: GatewayAccount;
63
- }): Promise<MediaSendResult>;
64
- }
65
-
66
- /** Delivery dependencies — injected when calling parseAndSendMediaTags / sendPlainReply. */
67
- export interface DeliverDeps {
68
- mediaSender: MediaSender;
69
- /** Text chunker — delegates to `runtime.channel.text.chunkMarkdownText`. */
70
- chunkText: (text: string, limit: number) => string[];
71
- }
72
-
73
- // ---- Exported types ----
74
-
75
- /** Maximum text length for a single QQ Bot message. */
76
- const TEXT_CHUNK_LIMIT = 5000;
77
-
78
- interface DeliverEventContext {
79
- type: "c2c" | "guild" | "dm" | "group";
80
- senderId: string;
81
- messageId: string;
82
- channelId?: string;
83
- guildId?: string;
84
- groupOpenid?: string;
85
- msgIdx?: string;
86
- }
87
-
88
- interface DeliverAccountContext {
89
- account: GatewayAccount;
90
- qualifiedTarget: string;
91
- log?: {
92
- info: (msg: string) => void;
93
- error: (msg: string) => void;
94
- debug?: (msg: string) => void;
95
- };
96
- }
97
-
98
- /** Wrapper that retries when the access token expires. */
99
- type SendWithRetryFn = <T>(sendFn: (token: string) => Promise<T>) => Promise<T>;
100
-
101
- /** Consume a quote ref exactly once. */
102
- type ConsumeQuoteRefFn = () => string | undefined;
103
-
104
- // ---- Internal helpers ----
105
-
106
- function resolveMediaTargetContext(
107
- event: DeliverEventContext,
108
- account: GatewayAccount,
109
- ): MediaTargetContext {
110
- return {
111
- targetType:
112
- event.type === "c2c"
113
- ? "c2c"
114
- : event.type === "group"
115
- ? "group"
116
- : event.type === "dm"
117
- ? "dm"
118
- : "channel",
119
- targetId:
120
- event.type === "c2c"
121
- ? event.senderId
122
- : event.type === "group"
123
- ? event.groupOpenid!
124
- : event.type === "dm"
125
- ? event.guildId!
126
- : event.channelId!,
127
- account,
128
- replyToId: event.messageId,
129
- };
130
- }
131
-
132
- async function autoMediaBatch(params: {
133
- qualifiedTarget: string;
134
- account: GatewayAccount;
135
- replyToId: string;
136
- mediaUrls: string[];
137
- mediaSender: MediaSender;
138
- log?: DeliverAccountContext["log"];
139
- onResultError: (mediaUrl: string, error: string) => string;
140
- onThrownError: (mediaUrl: string, error: string) => string;
141
- onSuccess?: (mediaUrl: string) => string | undefined;
142
- }): Promise<void> {
143
- for (const mediaUrl of params.mediaUrls) {
144
- try {
145
- const result = await params.mediaSender.sendMedia({
146
- to: params.qualifiedTarget,
147
- text: "",
148
- mediaUrl,
149
- accountId: params.account.accountId,
150
- replyToId: params.replyToId,
151
- account: params.account,
152
- });
153
- if (result.error) {
154
- params.log?.error(params.onResultError(mediaUrl, result.error));
155
- continue;
156
- }
157
- const successMessage = params.onSuccess?.(mediaUrl);
158
- if (successMessage) {
159
- params.log?.info(successMessage);
160
- }
161
- } catch (err) {
162
- params.log?.error(params.onThrownError(mediaUrl, formatErrorMessage(err)));
163
- }
164
- }
165
- }
166
-
167
- // ---- Text chunk sending ----
168
-
169
- async function sendTextChunkToTarget(params: {
170
- account: GatewayAccount;
171
- event: DeliverEventContext;
172
- token: string;
173
- text: string;
174
- consumeQuoteRef: ConsumeQuoteRefFn;
175
- allowDm: boolean;
176
- }): Promise<unknown> {
177
- const { account, event, text, consumeQuoteRef, allowDm } = params;
178
- const ref = consumeQuoteRef();
179
- const target = buildDeliveryTarget(event);
180
- if (target.type === "dm" && !allowDm) {
181
- return undefined;
182
- }
183
- const creds = accountToCreds(account);
184
- return await senderSendText(target, text, creds, {
185
- msgId: event.messageId,
186
- messageReference: ref,
187
- });
188
- }
189
-
190
- async function sendTextChunks(
191
- text: string,
192
- event: DeliverEventContext,
193
- actx: DeliverAccountContext,
194
- sendWithRetry: SendWithRetryFn,
195
- consumeQuoteRef: ConsumeQuoteRefFn,
196
- deps: DeliverDeps,
197
- ): Promise<void> {
198
- const { account, log } = actx;
199
- const chunks = deps.chunkText(text, TEXT_CHUNK_LIMIT);
200
- await sendTextChunksWithRetry({
201
- account,
202
- event,
203
- chunks,
204
- sendWithRetry,
205
- consumeQuoteRef,
206
- allowDm: true,
207
- log,
208
- onSuccess: (chunk) =>
209
- `Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`,
210
- onError: (err) => `Failed to send text chunk: ${formatErrorMessage(err)}`,
211
- });
212
- }
213
-
214
- async function sendTextChunksWithRetry(params: {
215
- account: GatewayAccount;
216
- event: DeliverEventContext;
217
- chunks: string[];
218
- sendWithRetry: SendWithRetryFn;
219
- consumeQuoteRef: ConsumeQuoteRefFn;
220
- allowDm: boolean;
221
- log?: DeliverAccountContext["log"];
222
- onSuccess: (chunk: string) => string;
223
- onError: (err: unknown) => string;
224
- }): Promise<void> {
225
- const { account, event, chunks, sendWithRetry, consumeQuoteRef, allowDm, log } = params;
226
- for (const chunk of chunks) {
227
- try {
228
- await sendWithRetry((token) =>
229
- sendTextChunkToTarget({
230
- account,
231
- event,
232
- token,
233
- text: chunk,
234
- consumeQuoteRef,
235
- allowDm,
236
- }),
237
- );
238
- log?.info(params.onSuccess(chunk));
239
- } catch (err) {
240
- log?.error(params.onError(err));
241
- }
242
- }
243
- }
244
-
245
- // ---- Result logging helpers ----
246
-
247
- async function sendWithResultLogging(params: {
248
- run: () => Promise<MediaSendResult>;
249
- log?: DeliverAccountContext["log"];
250
- onSuccess?: () => string | undefined;
251
- onError: (error: string) => string;
252
- }): Promise<void> {
253
- try {
254
- const result = await params.run();
255
- if (result.error) {
256
- params.log?.error(params.onError(result.error));
257
- return;
258
- }
259
- const successMessage = params.onSuccess?.();
260
- if (successMessage) {
261
- params.log?.info(successMessage);
262
- }
263
- } catch (err) {
264
- params.log?.error(params.onError(formatErrorMessage(err)));
265
- }
266
- }
267
-
268
- async function sendPhotoWithLogging(params: {
269
- target: MediaTargetContext;
270
- imageUrl: string;
271
- mediaSender: MediaSender;
272
- log?: DeliverAccountContext["log"];
273
- onSuccess?: (imageUrl: string) => string | undefined;
274
- onError: (error: string) => string;
275
- }): Promise<void> {
276
- await sendWithResultLogging({
277
- run: async () => await params.mediaSender.sendPhoto(params.target, params.imageUrl),
278
- log: params.log,
279
- onSuccess: params.onSuccess ? () => params.onSuccess?.(params.imageUrl) : undefined,
280
- onError: params.onError,
281
- });
282
- }
283
-
284
- /** Send voice with a 45s timeout guard. */
285
- async function sendVoiceWithTimeout(
286
- target: MediaTargetContext,
287
- voicePath: string,
288
- account: GatewayAccount,
289
- mediaSender: MediaSender,
290
- log: DeliverAccountContext["log"],
291
- ): Promise<void> {
292
- const uploadFormats =
293
- account.config?.audioFormatPolicy?.uploadDirectFormats ??
294
- account.config?.voiceDirectUploadFormats;
295
- const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
296
- const voiceTimeout = 45_000;
297
- const ac = new AbortController();
298
- try {
299
- const result = await Promise.race([
300
- mediaSender.sendVoice(target, voicePath, uploadFormats, transcodeEnabled).then((r) => {
301
- if (ac.signal.aborted) {
302
- log?.debug?.(`sendVoice completed after timeout, suppressing late delivery`);
303
- return {
304
- channel: "qqbot",
305
- error: "Voice send completed after timeout (suppressed)",
306
- } as typeof r;
307
- }
308
- return r;
309
- }),
310
- new Promise<{ channel: string; error: string }>((resolve) =>
311
- setTimeout(() => {
312
- ac.abort();
313
- resolve({ channel: "qqbot", error: "Voice send timed out and was skipped" });
314
- }, voiceTimeout),
315
- ),
316
- ]);
317
- if (result.error) {
318
- log?.error(`sendVoice error: ${result.error}`);
319
- }
320
- } catch (err) {
321
- log?.error(`sendVoice unexpected error: ${formatErrorMessage(err)}`);
322
- }
323
- }
324
-
325
- // ============ Public API ============
326
-
327
- /**
328
- * Parse media tags from the reply text and send them in order.
329
- *
330
- * @returns `true` when media tags were found and handled; `false` when the caller
331
- * should continue through the plain-text pipeline.
332
- */
333
- export async function parseAndSendMediaTags(
334
- replyText: string,
335
- event: DeliverEventContext,
336
- actx: DeliverAccountContext,
337
- sendWithRetry: SendWithRetryFn,
338
- consumeQuoteRef: ConsumeQuoteRefFn,
339
- deps: DeliverDeps,
340
- ): Promise<{ handled: boolean; normalizedText: string }> {
341
- const { account, log } = actx;
342
-
343
- const text = normalizeMediaTags(replyText);
344
-
345
- const mediaTagRegex =
346
- /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
347
- const mediaTagMatches = [...text.matchAll(mediaTagRegex)];
348
-
349
- if (mediaTagMatches.length === 0) {
350
- return { handled: false, normalizedText: text };
351
- }
352
-
353
- const tagCounts = mediaTagMatches.reduce<Record<string, number>>((acc, m) => {
354
- const t = normalizeLowercaseStringOrEmpty(m[1]);
355
- acc[t] = (acc[t] ?? 0) + 1;
356
- return acc;
357
- }, {});
358
- log?.debug?.(
359
- `Detected media tags: ${Object.entries(tagCounts)
360
- .map(([k, v]) => `${v} <${k}>`)
361
- .join(", ")}`,
362
- );
363
-
364
- type QueueItem = {
365
- type: "text" | "image" | "voice" | "video" | "file" | "media";
366
- content: string;
367
- };
368
- const sendQueue: QueueItem[] = [];
369
-
370
- let lastIndex = 0;
371
- const regex2 =
372
- /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
373
- let match;
374
-
375
- while ((match = regex2.exec(text)) !== null) {
376
- const textBefore = text
377
- .slice(lastIndex, match.index)
378
- .replace(/\n{3,}/g, "\n\n")
379
- .trim();
380
- if (textBefore) {
381
- sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
382
- }
383
-
384
- const tagName = normalizeLowercaseStringOrEmpty(match[1]);
385
- const mediaPath = decodeMediaPath(normalizeOptionalString(match[2]) ?? "", log);
386
-
387
- if (mediaPath) {
388
- const typeMap: Record<string, QueueItem["type"]> = {
389
- qqmedia: "media",
390
- qqvoice: "voice",
391
- qqvideo: "video",
392
- qqfile: "file",
393
- };
394
- const itemType = typeMap[tagName] ?? "image";
395
- sendQueue.push({ type: itemType, content: mediaPath });
396
- log?.debug?.(`Found ${itemType} in <${tagName}>: ${mediaPath}`);
397
- }
398
-
399
- lastIndex = match.index + match[0].length;
400
- }
401
-
402
- const textAfter = text
403
- .slice(lastIndex)
404
- .replace(/\n{3,}/g, "\n\n")
405
- .trim();
406
- if (textAfter) {
407
- sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) });
408
- }
409
-
410
- log?.debug?.(`Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`);
411
-
412
- const mediaTarget = resolveMediaTargetContext(event, account);
413
-
414
- for (const item of sendQueue) {
415
- if (item.type === "text") {
416
- await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef, deps);
417
- } else if (item.type === "image") {
418
- await sendPhotoWithLogging({
419
- target: mediaTarget,
420
- imageUrl: item.content,
421
- mediaSender: deps.mediaSender,
422
- log,
423
- onError: (error) => `sendPhoto error: ${error}`,
424
- });
425
- } else if (item.type === "voice") {
426
- await sendVoiceWithTimeout(mediaTarget, item.content, account, deps.mediaSender, log);
427
- } else if (item.type === "video") {
428
- await sendWithResultLogging({
429
- run: async () => await deps.mediaSender.sendVideoMsg(mediaTarget, item.content),
430
- log,
431
- onError: (error) => `sendVideoMsg error: ${error}`,
432
- });
433
- } else if (item.type === "file") {
434
- await sendWithResultLogging({
435
- run: async () => await deps.mediaSender.sendDocument(mediaTarget, item.content),
436
- log,
437
- onError: (error) => `sendDocument error: ${error}`,
438
- });
439
- } else if (item.type === "media") {
440
- await sendWithResultLogging({
441
- run: async () =>
442
- await deps.mediaSender.sendMedia({
443
- to: actx.qualifiedTarget,
444
- text: "",
445
- mediaUrl: item.content,
446
- accountId: account.accountId,
447
- replyToId: event.messageId,
448
- account,
449
- }),
450
- log,
451
- onError: (error) => `sendMedia(auto) error: ${error}`,
452
- });
453
- }
454
- }
455
-
456
- return { handled: true, normalizedText: text };
457
- }
458
-
459
- // ---- Plain reply ----
460
-
461
- interface PlainReplyPayload {
462
- text?: string;
463
- mediaUrls?: string[];
464
- mediaUrl?: string;
465
- audioAsVoice?: boolean;
466
- }
467
-
468
- /**
469
- * Send a reply that does not contain structured media tags.
470
- * Handles markdown image embeds, Base64 media, plain-text chunking, and local media routing.
471
- */
472
- export async function sendPlainReply(
473
- payload: PlainReplyPayload,
474
- replyText: string,
475
- event: DeliverEventContext,
476
- actx: DeliverAccountContext,
477
- sendWithRetry: SendWithRetryFn,
478
- consumeQuoteRef: ConsumeQuoteRefFn,
479
- toolMediaUrls: string[],
480
- deps: DeliverDeps,
481
- ): Promise<void> {
482
- const { account, qualifiedTarget, log } = actx;
483
-
484
- const collectedImageUrls: string[] = [];
485
- const localMediaToSend: string[] = [];
486
-
487
- const collectImageUrl = (url: string | undefined | null): boolean => {
488
- if (!url) {
489
- return false;
490
- }
491
- const isHttpUrl = url.startsWith("http://") || url.startsWith("https://");
492
- const isDataUrl = url.startsWith("data:image/");
493
- if (isHttpUrl || isDataUrl) {
494
- if (!collectedImageUrls.includes(url)) {
495
- collectedImageUrls.push(url);
496
- log?.debug?.(
497
- `Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : url.slice(0, 80) + "..."}`,
498
- );
499
- }
500
- return true;
501
- }
502
- if (isLocalFilePath(url)) {
503
- if (!localMediaToSend.includes(url)) {
504
- localMediaToSend.push(url);
505
- log?.debug?.(`Collected local media for auto-routing: ${url}`);
506
- }
507
- return true;
508
- }
509
- return false;
510
- };
511
-
512
- if (payload.mediaUrls?.length) {
513
- for (const url of payload.mediaUrls) {
514
- collectImageUrl(url);
515
- }
516
- }
517
- if (payload.mediaUrl) {
518
- collectImageUrl(payload.mediaUrl);
519
- }
520
-
521
- // Extract markdown images.
522
- const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi;
523
- const mdMatches = [...replyText.matchAll(mdImageRegex)];
524
- for (const m of mdMatches) {
525
- const url = m[2]?.trim();
526
- if (url && !collectedImageUrls.includes(url)) {
527
- if (url.startsWith("http://") || url.startsWith("https://")) {
528
- collectedImageUrls.push(url);
529
- log?.debug?.(`Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
530
- } else if (isLocalFilePath(url)) {
531
- if (!localMediaToSend.includes(url)) {
532
- localMediaToSend.push(url);
533
- log?.debug?.(`Collected local media from markdown for auto-routing: ${url}`);
534
- }
535
- }
536
- }
537
- }
538
-
539
- // Extract bare image URLs.
540
- const bareUrlRegex =
541
- /(?<![(["'])(https?:\/\/[^\s)"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi;
542
- const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)];
543
- for (const m of bareUrlMatches) {
544
- const url = m[1];
545
- if (url && !collectedImageUrls.includes(url)) {
546
- collectedImageUrls.push(url);
547
- log?.debug?.(`Extracted bare image URL: ${url.slice(0, 80)}...`);
548
- }
549
- }
550
-
551
- const useMarkdown = account.markdownSupport;
552
- log?.debug?.(`Markdown mode: ${useMarkdown}, images: ${collectedImageUrls.length}`);
553
-
554
- let textWithoutImages = filterInternalMarkers(replyText);
555
-
556
- for (const m of mdMatches) {
557
- const url = m[2]?.trim();
558
- if (url && !url.startsWith("http://") && !url.startsWith("https://") && !isLocalFilePath(url)) {
559
- textWithoutImages = textWithoutImages.replace(m[0], "").trim();
560
- }
561
- }
562
-
563
- if (useMarkdown) {
564
- await sendMarkdownReply(
565
- textWithoutImages,
566
- collectedImageUrls,
567
- mdMatches,
568
- bareUrlMatches,
569
- event,
570
- actx,
571
- sendWithRetry,
572
- consumeQuoteRef,
573
- deps,
574
- );
575
- } else {
576
- await sendPlainTextReply(
577
- textWithoutImages,
578
- collectedImageUrls,
579
- mdMatches,
580
- bareUrlMatches,
581
- event,
582
- actx,
583
- sendWithRetry,
584
- consumeQuoteRef,
585
- deps,
586
- );
587
- }
588
-
589
- // Send local media collected from payload.mediaUrl or markdown local paths.
590
- if (localMediaToSend.length > 0) {
591
- log?.debug?.(`Sending ${localMediaToSend.length} local media via sendMedia auto-routing`);
592
- await autoMediaBatch({
593
- qualifiedTarget,
594
- account,
595
- replyToId: event.messageId,
596
- mediaUrls: localMediaToSend,
597
- mediaSender: deps.mediaSender,
598
- log,
599
- onSuccess: (mediaPath) => `Sent local media: ${mediaPath}`,
600
- onResultError: (mediaPath, error) => `sendMedia(auto) error for ${mediaPath}: ${error}`,
601
- onThrownError: (mediaPath, error) => `sendMedia(auto) failed for ${mediaPath}: ${error}`,
602
- });
603
- }
604
-
605
- // Forward media gathered during the tool phase.
606
- if (toolMediaUrls.length > 0) {
607
- log?.debug?.(
608
- `Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`,
609
- );
610
- await autoMediaBatch({
611
- qualifiedTarget,
612
- account,
613
- replyToId: event.messageId,
614
- mediaUrls: toolMediaUrls,
615
- mediaSender: deps.mediaSender,
616
- log,
617
- onSuccess: (mediaUrl) => `Forwarded tool media: ${mediaUrl.slice(0, 80)}...`,
618
- onResultError: (_mediaUrl, error) => `Tool media forward error: ${error}`,
619
- onThrownError: (_mediaUrl, error) => `Tool media forward failed: ${error}`,
620
- });
621
- toolMediaUrls.length = 0;
622
- }
623
- }
624
-
625
- // ---- Markdown reply ----
626
-
627
- async function sendMarkdownReply(
628
- textWithoutImages: string,
629
- imageUrls: string[],
630
- mdMatches: RegExpMatchArray[],
631
- bareUrlMatches: RegExpMatchArray[],
632
- event: DeliverEventContext,
633
- actx: DeliverAccountContext,
634
- sendWithRetry: SendWithRetryFn,
635
- consumeQuoteRef: ConsumeQuoteRefFn,
636
- deps: DeliverDeps,
637
- ): Promise<void> {
638
- const { account, log } = actx;
639
-
640
- const httpImageUrls: string[] = [];
641
- const base64ImageUrls: string[] = [];
642
- for (const url of imageUrls) {
643
- if (url.startsWith("data:image/")) {
644
- base64ImageUrls.push(url);
645
- } else if (url.startsWith("http://") || url.startsWith("https://")) {
646
- httpImageUrls.push(url);
647
- }
648
- }
649
- log?.debug?.(
650
- `Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`,
651
- );
652
-
653
- // Send Base64 images via Rich Media API.
654
- if (base64ImageUrls.length > 0) {
655
- log?.debug?.(`Sending ${base64ImageUrls.length} image(s) via Rich Media API...`);
656
- for (const imageUrl of base64ImageUrls) {
657
- try {
658
- const target = buildDeliveryTarget(event);
659
- const creds = accountToCreds(account);
660
- if (target.type === "c2c" || target.type === "group") {
661
- await withTokenRetry(creds, async () => {
662
- await senderSendMedia({
663
- target,
664
- creds,
665
- kind: "image",
666
- source: { url: imageUrl },
667
- msgId: event.messageId,
668
- });
669
- });
670
- } else {
671
- log?.debug?.(`${target.type} does not support rich media, skipping Base64 image`);
672
- }
673
- log?.debug?.(`Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`);
674
- } catch (imgErr) {
675
- log?.error(`Failed to send Base64 image via Rich Media API: ${String(imgErr)}`);
676
- }
677
- }
678
- }
679
-
680
- // Handle public image URLs — format as markdown images with dimensions.
681
- const existingMdUrls = new Set(mdMatches.map((m) => m[2]));
682
- const imagesToAppend: string[] = [];
683
-
684
- for (const url of httpImageUrls) {
685
- if (!existingMdUrls.has(url)) {
686
- try {
687
- const size = await getImageSize(url);
688
- imagesToAppend.push(formatQQBotMarkdownImage(url, size));
689
- log?.debug?.(
690
- `Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${url.slice(0, 60)}...`,
691
- );
692
- } catch (err) {
693
- log?.debug?.(`Failed to get image size, using default: ${formatErrorMessage(err)}`);
694
- imagesToAppend.push(formatQQBotMarkdownImage(url, null));
695
- }
696
- }
697
- }
698
-
699
- // Backfill dimensions for existing markdown images.
700
- let result = textWithoutImages;
701
- for (const m of mdMatches) {
702
- const fullMatch = m[0];
703
- const imgUrl = m[2];
704
- const isHttpUrl = imgUrl.startsWith("http://") || imgUrl.startsWith("https://");
705
- if (isHttpUrl && !hasQQBotImageSize(fullMatch)) {
706
- try {
707
- const size = await getImageSize(imgUrl);
708
- result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size));
709
- log?.debug?.(
710
- `Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${imgUrl.slice(0, 60)}...`,
711
- );
712
- } catch (err) {
713
- log?.debug?.(
714
- `Failed to get image size for existing md, using default: ${formatErrorMessage(err)}`,
715
- );
716
- result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, null));
717
- }
718
- }
719
- }
720
-
721
- // Remove bare image URLs from text body.
722
- for (const m of bareUrlMatches) {
723
- result = result.replace(m[0], "").trim();
724
- }
725
-
726
- // Append markdown images.
727
- if (imagesToAppend.length > 0) {
728
- result = result.trim();
729
- result = result ? result + "\n\n" + imagesToAppend.join("\n") : imagesToAppend.join("\n");
730
- }
731
-
732
- // Send markdown text.
733
- if (result.trim()) {
734
- const mdChunks = deps.chunkText(result, TEXT_CHUNK_LIMIT);
735
- await sendTextChunksWithRetry({
736
- account,
737
- event,
738
- chunks: mdChunks,
739
- sendWithRetry,
740
- consumeQuoteRef,
741
- allowDm: true,
742
- log,
743
- onSuccess: (chunk) =>
744
- `Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`,
745
- onError: (err) => `Failed to send markdown message chunk: ${formatErrorMessage(err)}`,
746
- });
747
- }
748
- }
749
-
750
- // ---- Plain-text reply ----
751
-
752
- async function sendPlainTextReply(
753
- textWithoutImages: string,
754
- imageUrls: string[],
755
- mdMatches: RegExpMatchArray[],
756
- bareUrlMatches: RegExpMatchArray[],
757
- event: DeliverEventContext,
758
- actx: DeliverAccountContext,
759
- sendWithRetry: SendWithRetryFn,
760
- consumeQuoteRef: ConsumeQuoteRefFn,
761
- deps: DeliverDeps,
762
- ): Promise<void> {
763
- const { account, log } = actx;
764
-
765
- const imgMediaTarget = resolveMediaTargetContext(event, account);
766
-
767
- let result = textWithoutImages;
768
- for (const m of mdMatches) {
769
- result = result.replace(m[0], "").trim();
770
- }
771
- for (const m of bareUrlMatches) {
772
- result = result.replace(m[0], "").trim();
773
- }
774
-
775
- // QQ group messages reject some dotted bare URLs, so filter them first.
776
- if (result && event.type !== "c2c") {
777
- result = result.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
778
- }
779
-
780
- try {
781
- for (const imageUrl of imageUrls) {
782
- await sendPhotoWithLogging({
783
- target: imgMediaTarget,
784
- imageUrl,
785
- mediaSender: deps.mediaSender,
786
- log,
787
- onSuccess: (nextImageUrl) => `Sent image via sendPhoto: ${nextImageUrl.slice(0, 80)}...`,
788
- onError: (error) => `Failed to send image: ${error}`,
789
- });
790
- }
791
-
792
- if (result.trim()) {
793
- const plainChunks = deps.chunkText(result, TEXT_CHUNK_LIMIT);
794
- await sendTextChunksWithRetry({
795
- account,
796
- event,
797
- chunks: plainChunks,
798
- sendWithRetry,
799
- consumeQuoteRef,
800
- allowDm: false,
801
- log,
802
- onSuccess: (chunk) =>
803
- `Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`,
804
- onError: (err) => `Send failed: ${formatErrorMessage(err)}`,
805
- });
806
- }
807
- } catch (err) {
808
- log?.error(`Send failed: ${formatErrorMessage(err)}`);
809
- }
810
- }