@openclaw/qqbot 2026.5.2 → 2026.5.3-beta.1

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,597 +0,0 @@
1
- /**
2
- * Reply dispatcher — structured payload handling and text routing.
3
- *
4
- * Uses the unified `sender.ts` business function layer for all message
5
- * sending. TTS is injected via `ReplyDispatcherDeps`.
6
- */
7
-
8
- import crypto from "node:crypto";
9
- import path from "node:path";
10
- import { MediaFileType, type GatewayAccount } from "../types.js";
11
- import { formatFileSize, getImageMimeType, getMaxUploadSize } from "../utils/file-utils.js";
12
- import { formatErrorMessage } from "../utils/format.js";
13
- import {
14
- parseQQBotPayload,
15
- encodePayloadForCron,
16
- isCronReminderPayload,
17
- isMediaPayload,
18
- type MediaPayload,
19
- } from "../utils/payload.js";
20
- import { normalizePath, resolveQQBotPayloadLocalFilePath } from "../utils/platform.js";
21
- import { normalizeLowercaseStringOrEmpty } from "../utils/string-normalize.js";
22
- import { sanitizeFileName } from "../utils/string-normalize.js";
23
- import { openLocalFile } from "./media-source.js";
24
- import {
25
- sendText as senderSendText,
26
- sendMedia as senderSendMedia,
27
- withTokenRetry,
28
- buildDeliveryTarget,
29
- accountToCreds,
30
- } from "./sender.js";
31
-
32
- // ---- Injected dependencies ----
33
-
34
- /** TTS provider interface — injected from the outer layer. */
35
- interface TTSProvider {
36
- /** Framework TTS: text → audio file path. */
37
- textToSpeech(params: {
38
- text: string;
39
- cfg: unknown;
40
- channel: string;
41
- accountId?: string;
42
- }): Promise<{
43
- success: boolean;
44
- audioPath?: string;
45
- provider?: string;
46
- outputFormat?: string;
47
- error?: string;
48
- }>;
49
- /** Convert any audio file to SILK base64. */
50
- audioFileToSilkBase64(audioPath: string): Promise<string | undefined>;
51
- }
52
-
53
- /** Dependencies injected into reply-dispatcher functions. */
54
- export interface ReplyDispatcherDeps {
55
- tts: TTSProvider;
56
- }
57
-
58
- // ---- Exported types ----
59
-
60
- interface MessageTarget {
61
- type: "c2c" | "guild" | "dm" | "group";
62
- senderId: string;
63
- messageId: string;
64
- channelId?: string;
65
- guildId?: string;
66
- groupOpenid?: string;
67
- }
68
-
69
- interface ReplyContext {
70
- target: MessageTarget;
71
- account: GatewayAccount;
72
- cfg: unknown;
73
- log?: {
74
- info: (msg: string) => void;
75
- error: (msg: string) => void;
76
- debug?: (msg: string) => void;
77
- };
78
- }
79
-
80
- // ---- Token retry (delegated to sender.ts) ----
81
-
82
- /** Send a message and retry once if the token appears to have expired. */
83
- export async function sendWithTokenRetry<T>(
84
- appId: string,
85
- clientSecret: string,
86
- sendFn: (token: string) => Promise<T>,
87
- log?: ReplyContext["log"],
88
- accountId?: string,
89
- ): Promise<T> {
90
- return withTokenRetry({ appId, clientSecret }, sendFn, log, accountId);
91
- }
92
-
93
- // ---- Text routing ----
94
-
95
- /** Route a text message to the correct QQ target type. */
96
- async function sendTextToTarget(ctx: ReplyContext, text: string, refIdx?: string): Promise<void> {
97
- const { target, account } = ctx;
98
- const deliveryTarget = buildDeliveryTarget(target);
99
- const creds = accountToCreds(account);
100
- await withTokenRetry(
101
- creds,
102
- async () => {
103
- await senderSendText(deliveryTarget, text, creds, {
104
- msgId: target.messageId,
105
- messageReference: refIdx,
106
- });
107
- },
108
- ctx.log,
109
- account.accountId,
110
- );
111
- }
112
-
113
- /** Best-effort delivery for error text back to the user. */
114
- export async function sendErrorToTarget(ctx: ReplyContext, errorText: string): Promise<void> {
115
- try {
116
- await sendTextToTarget(ctx, errorText);
117
- } catch (sendErr) {
118
- ctx.log?.error(`Failed to send error message: ${String(sendErr)}`);
119
- }
120
- }
121
-
122
- // ---- Structured payload handling ----
123
-
124
- /**
125
- * Handle a structured payload prefixed with `QQBOT_PAYLOAD:`.
126
- * Returns true when the reply was handled here, otherwise false.
127
- */
128
- export async function handleStructuredPayload(
129
- ctx: ReplyContext,
130
- replyText: string,
131
- recordActivity: () => void,
132
- deps?: ReplyDispatcherDeps,
133
- ): Promise<boolean> {
134
- const { account: _account, log } = ctx;
135
- const payloadResult = parseQQBotPayload(replyText);
136
-
137
- if (!payloadResult.isPayload) {
138
- return false;
139
- }
140
-
141
- if (payloadResult.error) {
142
- log?.error(`Payload parse error: ${payloadResult.error}`);
143
- return true;
144
- }
145
-
146
- if (!payloadResult.payload) {
147
- return true;
148
- }
149
-
150
- const parsedPayload = payloadResult.payload;
151
- const unknownPayload = payloadResult.payload as unknown;
152
- log?.info(`Detected structured payload, type: ${parsedPayload.type}`);
153
-
154
- if (isCronReminderPayload(parsedPayload)) {
155
- log?.debug?.(`Processing cron_reminder payload`);
156
- const cronMessage = encodePayloadForCron(parsedPayload);
157
- const confirmText = `⏰ Reminder scheduled. It will be sent at the configured time: "${parsedPayload.content}"`;
158
- try {
159
- await sendTextToTarget(ctx, confirmText);
160
- log?.debug?.(`Cron reminder confirmation sent, cronMessage: ${cronMessage}`);
161
- } catch (err) {
162
- log?.error(`Failed to send cron confirmation: ${formatErrorMessage(err)}`);
163
- }
164
- recordActivity();
165
- return true;
166
- }
167
-
168
- if (isMediaPayload(parsedPayload)) {
169
- log?.debug?.(`Processing media payload, mediaType: ${parsedPayload.mediaType}`);
170
-
171
- if (parsedPayload.mediaType === "image") {
172
- await handleImagePayload(ctx, parsedPayload);
173
- } else if (parsedPayload.mediaType === "audio") {
174
- await handleAudioPayload(ctx, parsedPayload, deps);
175
- } else if (parsedPayload.mediaType === "video") {
176
- await handleVideoPayload(ctx, parsedPayload);
177
- } else if (parsedPayload.mediaType === "file") {
178
- await handleFilePayload(ctx, parsedPayload);
179
- } else {
180
- log?.error(`Unknown media type: ${JSON.stringify(parsedPayload.mediaType)}`);
181
- }
182
- recordActivity();
183
- return true;
184
- }
185
-
186
- const payloadType =
187
- typeof unknownPayload === "object" &&
188
- unknownPayload !== null &&
189
- "type" in unknownPayload &&
190
- typeof unknownPayload.type === "string"
191
- ? unknownPayload.type
192
- : "unknown";
193
- log?.error(`Unknown payload type: ${payloadType}`);
194
- return true;
195
- }
196
-
197
- // ---- Media payload handlers ----
198
-
199
- type StructuredPayloadMediaType = "image" | "video" | "file";
200
-
201
- function formatMediaTypeLabel(mediaType: StructuredPayloadMediaType): string {
202
- return mediaType[0].toUpperCase() + mediaType.slice(1);
203
- }
204
-
205
- function validateStructuredPayloadLocalPath(
206
- ctx: ReplyContext,
207
- payloadPath: string,
208
- mediaType: StructuredPayloadMediaType,
209
- ): string | null {
210
- const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath);
211
- if (allowedPath) {
212
- return allowedPath;
213
- }
214
-
215
- ctx.log?.error(`Blocked ${mediaType} payload local path outside QQ Bot media storage`);
216
- return null;
217
- }
218
-
219
- function isRemoteHttpUrl(p: string): boolean {
220
- return p.startsWith("http://") || p.startsWith("https://");
221
- }
222
-
223
- function isInlineImageDataUrl(p: string): boolean {
224
- return /^data:image\/[^;]+;base64,/i.test(p);
225
- }
226
-
227
- function resolveStructuredPayloadPath(
228
- ctx: ReplyContext,
229
- payload: MediaPayload,
230
- mediaType: StructuredPayloadMediaType,
231
- ): { path: string; isHttpUrl: boolean } | null {
232
- const originalPath = payload.path ?? "";
233
- const normalizedPath = normalizePath(originalPath);
234
- const isHttpUrl = isRemoteHttpUrl(normalizedPath);
235
- const resolvedPath = isHttpUrl
236
- ? normalizedPath
237
- : validateStructuredPayloadLocalPath(ctx, originalPath, mediaType);
238
- if (!resolvedPath) {
239
- return null;
240
- }
241
- if (!resolvedPath.trim()) {
242
- ctx.log?.error(
243
- `[qqbot:${ctx.account.accountId}] ${formatMediaTypeLabel(mediaType)} missing path`,
244
- );
245
- return null;
246
- }
247
- return { path: resolvedPath, isHttpUrl };
248
- }
249
-
250
- function sanitizeForLog(value: string, maxLen = 200): string {
251
- return value
252
- .replace(/[\r\n\t]/g, " ")
253
- .replaceAll("\0", " ")
254
- .slice(0, maxLen);
255
- }
256
-
257
- function describeMediaTargetForLog(pathValue: string, isHttpUrl: boolean): string {
258
- if (!isHttpUrl) {
259
- return "<local-file>";
260
- }
261
- try {
262
- const url = new URL(pathValue);
263
- url.username = "";
264
- url.password = "";
265
- const urlId = crypto.createHash("sha256").update(url.toString()).digest("hex").slice(0, 12);
266
- return sanitizeForLog(`${url.protocol}//${url.host}#${urlId}`);
267
- } catch {
268
- return "<invalid-url>";
269
- }
270
- }
271
-
272
- /**
273
- * Read a local file into memory for image base64 inlining.
274
- *
275
- * Non-image media (video / file) should pass `source: { localPath }` to
276
- * `sender.sendMedia` directly — the sender pipeline handles chunked
277
- * routing once this function validates the per-type ceiling.
278
- */
279
- async function readLocalFileForInlineBase64(
280
- filePath: string,
281
- fileType: MediaFileType,
282
- ): Promise<Buffer> {
283
- const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) });
284
- try {
285
- return await opened.handle.readFile();
286
- } finally {
287
- await opened.close();
288
- }
289
- }
290
-
291
- /**
292
- * Enforce the per-{@link MediaFileType} upload ceiling before handing a
293
- * local path to `sender.sendMedia`. The sender's internal `normalizeSource`
294
- * uses an unlimited cap so it can accept whatever size the policy layer
295
- * (outbound / reply-dispatcher) approves; the policy gate lives here.
296
- *
297
- * Returns the validated byte size. Throws via {@link openLocalFile} with a
298
- * human-readable "File is too large" message when exceeding the ceiling.
299
- */
300
- async function assertLocalFileWithinTypeLimit(
301
- filePath: string,
302
- fileType: MediaFileType,
303
- ): Promise<number> {
304
- const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) });
305
- try {
306
- return opened.size;
307
- } finally {
308
- await opened.close();
309
- }
310
- }
311
-
312
- async function handleImagePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
313
- const { target, account, log } = ctx;
314
- const normalizedPath = normalizePath(payload.path);
315
- let imageUrl: string | null;
316
- if (payload.source === "file") {
317
- imageUrl = validateStructuredPayloadLocalPath(ctx, normalizedPath, "image");
318
- } else if (isRemoteHttpUrl(normalizedPath) || isInlineImageDataUrl(normalizedPath)) {
319
- imageUrl = normalizedPath;
320
- } else {
321
- log?.error(
322
- `Image payload URL must use http(s) or data:image/: ${sanitizeForLog(payload.path)}`,
323
- );
324
- return;
325
- }
326
- if (!imageUrl) {
327
- return;
328
- }
329
- const originalImagePath = payload.source === "file" ? imageUrl : undefined;
330
-
331
- if (payload.source === "file") {
332
- try {
333
- const fileBuffer = await readLocalFileForInlineBase64(imageUrl, MediaFileType.IMAGE);
334
- const base64Data = fileBuffer.toString("base64");
335
- const mimeType = getImageMimeType(imageUrl);
336
- if (!mimeType) {
337
- const ext = normalizeLowercaseStringOrEmpty(path.extname(imageUrl));
338
- log?.error(`Unsupported image format: ${ext}`);
339
- return;
340
- }
341
- imageUrl = `data:${mimeType};base64,${base64Data}`;
342
- log?.debug?.(`Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
343
- } catch (readErr) {
344
- log?.error(
345
- `Failed to read local image: ${
346
- readErr instanceof Error ? readErr.message : JSON.stringify(readErr)
347
- }`,
348
- );
349
- return;
350
- }
351
- }
352
-
353
- try {
354
- const deliveryTarget = buildDeliveryTarget(target);
355
- const creds = accountToCreds(account);
356
-
357
- await withTokenRetry(
358
- creds,
359
- async () => {
360
- if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") {
361
- await senderSendMedia({
362
- target: deliveryTarget,
363
- creds,
364
- kind: "image",
365
- source: { url: imageUrl },
366
- msgId: target.messageId,
367
- localPathForMeta: originalImagePath,
368
- });
369
- } else if (deliveryTarget.type === "dm") {
370
- await senderSendText(deliveryTarget, `![](${payload.path})`, creds, {
371
- msgId: target.messageId,
372
- });
373
- } else {
374
- await senderSendText(deliveryTarget, `![](${payload.path})`, creds, {
375
- msgId: target.messageId,
376
- });
377
- }
378
- },
379
- log,
380
- account.accountId,
381
- );
382
- log?.debug?.(`Sent image via media payload`);
383
-
384
- if (payload.caption) {
385
- await sendTextToTarget(ctx, payload.caption);
386
- }
387
- } catch (err) {
388
- log?.error(`Failed to send image: ${formatErrorMessage(err)}`);
389
- }
390
- }
391
-
392
- async function handleAudioPayload(
393
- ctx: ReplyContext,
394
- payload: MediaPayload,
395
- deps?: ReplyDispatcherDeps,
396
- ): Promise<void> {
397
- const ttsText = payload.caption || payload.path;
398
- await sendTextAsVoiceReply(ctx, ttsText, deps);
399
- }
400
-
401
- export async function sendTextAsVoiceReply(
402
- ctx: ReplyContext,
403
- text: string | undefined,
404
- deps?: ReplyDispatcherDeps,
405
- ): Promise<boolean> {
406
- const { target, account, cfg, log } = ctx;
407
- if (!deps) {
408
- log?.error(`TTS deps not provided, cannot handle audio payload`);
409
- return false;
410
- }
411
- try {
412
- const ttsText = text;
413
- if (!ttsText?.trim()) {
414
- log?.error(`Voice missing text`);
415
- return false;
416
- }
417
-
418
- log?.debug?.(`TTS: "${ttsText.slice(0, 50)}..."`);
419
- const ttsResult = await deps.tts.textToSpeech({
420
- text: ttsText,
421
- cfg,
422
- channel: "qqbot",
423
- accountId: account.accountId,
424
- });
425
- if (!ttsResult.success || !ttsResult.audioPath) {
426
- log?.error(`TTS failed: ${ttsResult.error ?? "unknown"}`);
427
- return false;
428
- }
429
-
430
- const providerLabel = ttsResult.provider ?? "unknown";
431
- log?.debug?.(
432
- `TTS returned: provider=${providerLabel}, format=${ttsResult.outputFormat}, path=${ttsResult.audioPath}`,
433
- );
434
-
435
- const silkBase64 = await deps.tts.audioFileToSilkBase64(ttsResult.audioPath);
436
- if (!silkBase64) {
437
- log?.error(`Failed to convert TTS audio to SILK`);
438
- return false;
439
- }
440
- const silkPath = ttsResult.audioPath;
441
-
442
- log?.debug?.(`TTS done (${providerLabel}), file: ${silkPath}`);
443
-
444
- const deliveryTarget = buildDeliveryTarget(target);
445
- const creds = accountToCreds(account);
446
-
447
- await withTokenRetry(
448
- creds,
449
- async () => {
450
- if (deliveryTarget.type === "c2c" || deliveryTarget.type === "group") {
451
- await senderSendMedia({
452
- target: deliveryTarget,
453
- creds,
454
- kind: "voice",
455
- source: { base64: silkBase64 },
456
- msgId: target.messageId,
457
- ttsText,
458
- localPathForMeta: silkPath,
459
- });
460
- } else {
461
- log?.error(`Voice not supported in ${deliveryTarget.type}, sending text fallback`);
462
- await senderSendText(deliveryTarget, ttsText, creds, { msgId: target.messageId });
463
- }
464
- },
465
- log,
466
- account.accountId,
467
- );
468
- log?.debug?.(`Voice message sent`);
469
- return true;
470
- } catch (err) {
471
- log?.error(`TTS/voice send failed: ${formatErrorMessage(err)}`);
472
- return false;
473
- }
474
- }
475
-
476
- async function handleVideoPayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
477
- const { target, account, log } = ctx;
478
- try {
479
- const resolved = resolveStructuredPayloadPath(ctx, payload, "video");
480
- if (!resolved) {
481
- return;
482
- }
483
- const videoPath = resolved.path;
484
- const isHttpUrl = resolved.isHttpUrl;
485
-
486
- log?.debug?.(`Video send: ${describeMediaTargetForLog(videoPath, isHttpUrl)}`);
487
-
488
- const deliveryTarget = buildDeliveryTarget(target);
489
- const creds = accountToCreds(account);
490
-
491
- if (deliveryTarget.type !== "c2c" && deliveryTarget.type !== "group") {
492
- log?.error(`Video not supported in ${deliveryTarget.type}`);
493
- return;
494
- }
495
-
496
- await withTokenRetry(
497
- creds,
498
- async () => {
499
- if (isHttpUrl) {
500
- await senderSendMedia({
501
- target: deliveryTarget,
502
- creds,
503
- kind: "video",
504
- source: { url: videoPath },
505
- msgId: target.messageId,
506
- });
507
- } else {
508
- const size = await assertLocalFileWithinTypeLimit(videoPath, MediaFileType.VIDEO);
509
- log?.debug?.(
510
- `Video local (${formatFileSize(size)}): ${describeMediaTargetForLog(videoPath, false)}`,
511
- );
512
- // Hand the local path straight to the sender — `dispatchUpload`
513
- // routes one-shot vs chunked based on size.
514
- await senderSendMedia({
515
- target: deliveryTarget,
516
- creds,
517
- kind: "video",
518
- source: { localPath: videoPath },
519
- msgId: target.messageId,
520
- localPathForMeta: videoPath,
521
- });
522
- }
523
- },
524
- log,
525
- account.accountId,
526
- );
527
- log?.debug?.(`Video message sent`);
528
-
529
- if (payload.caption) {
530
- await sendTextToTarget(ctx, payload.caption);
531
- }
532
- } catch (err) {
533
- log?.error(`Video send failed: ${formatErrorMessage(err)}`);
534
- }
535
- }
536
-
537
- async function handleFilePayload(ctx: ReplyContext, payload: MediaPayload): Promise<void> {
538
- const { target, account, log } = ctx;
539
- try {
540
- const resolved = resolveStructuredPayloadPath(ctx, payload, "file");
541
- if (!resolved) {
542
- return;
543
- }
544
- const filePath = resolved.path;
545
- const isHttpUrl = resolved.isHttpUrl;
546
-
547
- const fileName = sanitizeFileName(path.basename(filePath));
548
- log?.debug?.(
549
- `File send: ${describeMediaTargetForLog(filePath, isHttpUrl)} (${isHttpUrl ? "URL" : "local"})`,
550
- );
551
-
552
- const deliveryTarget = buildDeliveryTarget(target);
553
- const creds = accountToCreds(account);
554
-
555
- if (deliveryTarget.type !== "c2c" && deliveryTarget.type !== "group") {
556
- log?.error(`File not supported in ${deliveryTarget.type}`);
557
- return;
558
- }
559
-
560
- await withTokenRetry(
561
- creds,
562
- async () => {
563
- if (isHttpUrl) {
564
- await senderSendMedia({
565
- target: deliveryTarget,
566
- creds,
567
- kind: "file",
568
- source: { url: filePath },
569
- msgId: target.messageId,
570
- fileName,
571
- });
572
- } else {
573
- const size = await assertLocalFileWithinTypeLimit(filePath, MediaFileType.FILE);
574
- log?.debug?.(
575
- `File local (${formatFileSize(size)}): ${describeMediaTargetForLog(filePath, false)}`,
576
- );
577
- // Hand the local path straight to the sender — `dispatchUpload`
578
- // routes one-shot vs chunked based on size.
579
- await senderSendMedia({
580
- target: deliveryTarget,
581
- creds,
582
- kind: "file",
583
- source: { localPath: filePath },
584
- msgId: target.messageId,
585
- fileName,
586
- localPathForMeta: filePath,
587
- });
588
- }
589
- },
590
- log,
591
- account.accountId,
592
- );
593
- log?.debug?.(`File message sent`);
594
- } catch (err) {
595
- log?.error(`File send failed: ${formatErrorMessage(err)}`);
596
- }
597
- }