@openclaw/qqbot 2026.5.2-beta.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,702 +0,0 @@
1
- /**
2
- * Low-level outbound media sends (photo, voice, video, document) and path resolution.
3
- */
4
-
5
- import fs from "node:fs";
6
- import path from "node:path";
7
- import type { GatewayAccount } from "../types.js";
8
- import { MediaFileType } from "../types.js";
9
- import {
10
- checkFileSize,
11
- downloadFile,
12
- fileExistsAsync,
13
- formatFileSize,
14
- getImageMimeType,
15
- getMaxUploadSize,
16
- readFileAsync,
17
- } from "../utils/file-utils.js";
18
- import { formatErrorMessage } from "../utils/format.js";
19
- import { debugError, debugLog, debugWarn } from "../utils/log.js";
20
- import {
21
- getQQBotDataDir,
22
- getQQBotMediaDir,
23
- isLocalPath as isLocalFilePath,
24
- normalizePath,
25
- resolveQQBotPayloadLocalFilePath,
26
- } from "../utils/platform.js";
27
- import { normalizeLowercaseStringOrEmpty, sanitizeFileName } from "../utils/string-normalize.js";
28
- import { audioFileToSilkBase64, shouldTranscodeVoice, waitForFile } from "./outbound-audio-port.js";
29
- import {
30
- buildDailyLimitExceededResult,
31
- buildFileTooLargeResult,
32
- } from "./outbound-result-helpers.js";
33
- import type { MediaTargetContext, OutboundResult } from "./outbound-types.js";
34
- import {
35
- accountToCreds,
36
- sendMedia as senderSendMedia,
37
- sendText as senderSendText,
38
- UploadDailyLimitExceededError,
39
- type DeliveryTarget,
40
- } from "./sender.js";
41
- import { parseTarget as coreParseTarget } from "./target-parser.js";
42
-
43
- /** Parse a qqbot target into a structured delivery target. */
44
- export function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
45
- const timestamp = new Date().toISOString();
46
- debugLog(`[${timestamp}] [qqbot] parseTarget: input=${to}`);
47
- const parsed = coreParseTarget(to);
48
- debugLog(`[${timestamp}] [qqbot] parseTarget: ${parsed.type} target, ID=${parsed.id}`);
49
- return parsed;
50
- }
51
-
52
- // Structured media send helpers shared by gateway delivery and sendText.
53
-
54
- /** Build a media target from a normal outbound context. */
55
- export function buildMediaTarget(ctx: {
56
- to: string;
57
- account: GatewayAccount;
58
- replyToId?: string | null;
59
- }): MediaTargetContext {
60
- const target = parseTarget(ctx.to);
61
- return {
62
- targetType: target.type,
63
- targetId: target.id,
64
- account: ctx.account,
65
- replyToId: ctx.replyToId ?? undefined,
66
- };
67
- }
68
-
69
- /** Return true when public URLs should be passed through directly. */
70
- function shouldDirectUploadUrl(account: GatewayAccount): boolean {
71
- return account.config?.urlDirectUpload !== false;
72
- }
73
-
74
- type QQBotMediaKind = "image" | "voice" | "video" | "file" | "media";
75
-
76
- const qqBotMediaKindLabel: Record<QQBotMediaKind, string> = {
77
- image: "Image",
78
- voice: "Voice",
79
- video: "Video",
80
- file: "File",
81
- media: "Media",
82
- };
83
-
84
- type ResolvedOutboundMediaPath = { ok: true; mediaPath: string } | { ok: false; error: string };
85
- type ResolveOutboundMediaPathOptions = {
86
- allowMissingLocalPath?: boolean;
87
- extraLocalRoots?: string[];
88
- };
89
- type SendDocumentOptions = {
90
- allowQQBotDataDownloads?: boolean;
91
- };
92
-
93
- function isHttpOrDataSource(pathValue: string): boolean {
94
- return (
95
- pathValue.startsWith("http://") ||
96
- pathValue.startsWith("https://") ||
97
- pathValue.startsWith("data:")
98
- );
99
- }
100
-
101
- function isPathWithinRoot(candidate: string, root: string): boolean {
102
- const relative = path.relative(root, candidate);
103
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
104
- }
105
-
106
- function resolveMissingPathWithinMediaRoot(normalizedPath: string): string | null {
107
- const resolvedCandidate = path.resolve(normalizedPath);
108
- if (fs.existsSync(resolvedCandidate)) {
109
- return null;
110
- }
111
-
112
- const allowedRoot = path.resolve(getQQBotMediaDir());
113
- let canonicalAllowedRoot: string;
114
- try {
115
- canonicalAllowedRoot = fs.realpathSync(allowedRoot);
116
- } catch {
117
- return null;
118
- }
119
-
120
- const missingSegments: string[] = [];
121
- let cursor = resolvedCandidate;
122
- while (!fs.existsSync(cursor)) {
123
- const parent = path.dirname(cursor);
124
- if (parent === cursor) {
125
- break;
126
- }
127
- missingSegments.unshift(path.basename(cursor));
128
- cursor = parent;
129
- }
130
-
131
- if (!fs.existsSync(cursor)) {
132
- return null;
133
- }
134
-
135
- let canonicalCursor: string;
136
- try {
137
- canonicalCursor = fs.realpathSync(cursor);
138
- } catch {
139
- return null;
140
- }
141
- const canonicalCandidate =
142
- missingSegments.length > 0 ? path.join(canonicalCursor, ...missingSegments) : canonicalCursor;
143
-
144
- return isPathWithinRoot(canonicalCandidate, canonicalAllowedRoot) ? canonicalCandidate : null;
145
- }
146
-
147
- function resolveExistingPathWithinRoots(
148
- normalizedPath: string,
149
- allowedRoots: readonly string[],
150
- ): string | null {
151
- const resolvedCandidate = path.resolve(normalizedPath);
152
- if (!fs.existsSync(resolvedCandidate)) {
153
- return null;
154
- }
155
-
156
- let canonicalCandidate: string;
157
- try {
158
- canonicalCandidate = fs.realpathSync(resolvedCandidate);
159
- } catch {
160
- return null;
161
- }
162
-
163
- for (const root of allowedRoots) {
164
- const resolvedRoot = path.resolve(root);
165
- const canonicalRoot = fs.existsSync(resolvedRoot)
166
- ? fs.realpathSync(resolvedRoot)
167
- : resolvedRoot;
168
- if (isPathWithinRoot(canonicalCandidate, canonicalRoot)) {
169
- return canonicalCandidate;
170
- }
171
- }
172
-
173
- return null;
174
- }
175
-
176
- export function resolveOutboundMediaPath(
177
- rawPath: string,
178
- mediaKind: QQBotMediaKind,
179
- options: ResolveOutboundMediaPathOptions = {},
180
- ): ResolvedOutboundMediaPath {
181
- const normalizedPath = normalizePath(rawPath);
182
- if (isHttpOrDataSource(normalizedPath)) {
183
- return { ok: true, mediaPath: normalizedPath };
184
- }
185
-
186
- const allowedPath = resolveQQBotPayloadLocalFilePath(normalizedPath);
187
- if (allowedPath) {
188
- return { ok: true, mediaPath: allowedPath };
189
- }
190
-
191
- if (options.extraLocalRoots && options.extraLocalRoots.length > 0) {
192
- const extraAllowedPath = resolveExistingPathWithinRoots(
193
- normalizedPath,
194
- options.extraLocalRoots,
195
- );
196
- if (extraAllowedPath) {
197
- return { ok: true, mediaPath: extraAllowedPath };
198
- }
199
- }
200
-
201
- if (options.allowMissingLocalPath) {
202
- const allowedMissingPath = resolveMissingPathWithinMediaRoot(normalizedPath);
203
- if (allowedMissingPath) {
204
- return { ok: true, mediaPath: allowedMissingPath };
205
- }
206
- }
207
-
208
- debugWarn(`blocked local ${mediaKind} path outside QQ Bot media storage`);
209
- return {
210
- ok: false,
211
- error: `${qqBotMediaKindLabel[mediaKind]} path must be inside QQ Bot media storage`,
212
- };
213
- }
214
-
215
- /**
216
- * Send a photo from a local file, public URL, or Base64 data URL.
217
- */
218
- export async function sendPhoto(
219
- ctx: MediaTargetContext,
220
- imagePath: string,
221
- ): Promise<OutboundResult> {
222
- const resolvedMediaPath = resolveOutboundMediaPath(imagePath, "image");
223
- if (!resolvedMediaPath.ok) {
224
- return { channel: "qqbot", error: resolvedMediaPath.error };
225
- }
226
- const mediaPath = resolvedMediaPath.mediaPath;
227
- const isLocal = isLocalFilePath(mediaPath);
228
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
229
- const isData = mediaPath.startsWith("data:");
230
-
231
- // Force a local download before upload when direct URL upload is disabled.
232
- if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
233
- debugLog(`sendPhoto: urlDirectUpload=false, downloading URL first...`);
234
- const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
235
- if (localFile) {
236
- return await sendPhotoFromLocal(ctx, localFile);
237
- }
238
- return { channel: "qqbot", error: `Failed to download image: ${mediaPath.slice(0, 80)}` };
239
- }
240
-
241
- if (isLocal) {
242
- return await sendPhotoFromLocal(ctx, mediaPath);
243
- }
244
-
245
- if (!isHttp && !isData) {
246
- return { channel: "qqbot", error: `Unsupported image source: ${mediaPath.slice(0, 50)}` };
247
- }
248
-
249
- // Remote URL or data: URL — try direct upload first, fall back to
250
- // download-then-local on failure.
251
- try {
252
- const creds = accountToCreds(ctx.account);
253
- const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
254
-
255
- if (target.type === "c2c" || target.type === "group") {
256
- const r = await senderSendMedia({
257
- target,
258
- creds,
259
- kind: "image",
260
- source: { url: mediaPath },
261
- msgId: ctx.replyToId,
262
- });
263
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
264
- }
265
-
266
- if (isHttp) {
267
- const r = await senderSendText(target, `![](${mediaPath})`, creds, {
268
- msgId: ctx.replyToId,
269
- });
270
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
271
- }
272
- debugLog(`sendPhoto: channel does not support local/Base64 images`);
273
- return { channel: "qqbot", error: "Channel does not support local/Base64 images" };
274
- } catch (err) {
275
- const msg = formatErrorMessage(err);
276
-
277
- // Fall back to plugin-managed download + local upload when QQ fails to
278
- // fetch the URL directly. One-shot, non-recursive.
279
- if (isHttp && !isData) {
280
- debugWarn(
281
- `sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
282
- );
283
- const localFile = await downloadToFallbackDir(mediaPath, "sendPhoto");
284
- if (localFile) {
285
- return await sendPhotoFromLocal(ctx, localFile);
286
- }
287
- }
288
-
289
- debugError(`sendPhoto failed: ${msg}`);
290
- return { channel: "qqbot", error: msg };
291
- }
292
- }
293
-
294
- /** Send a photo from a validated local file path. */
295
- async function sendPhotoFromLocal(
296
- ctx: MediaTargetContext,
297
- mediaPath: string,
298
- ): Promise<OutboundResult> {
299
- if (!(await fileExistsAsync(mediaPath))) {
300
- return { channel: "qqbot", error: "Image not found" };
301
- }
302
- const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.IMAGE));
303
- if (!sizeCheck.ok) {
304
- return buildFileTooLargeResult(MediaFileType.IMAGE, sizeCheck.size);
305
- }
306
- const mimeType = getImageMimeType(mediaPath);
307
- if (!mimeType) {
308
- const ext = normalizeLowercaseStringOrEmpty(path.extname(mediaPath));
309
- return { channel: "qqbot", error: `Unsupported image format: ${ext}` };
310
- }
311
- debugLog(`sendPhoto: local (${formatFileSize(sizeCheck.size)})`);
312
-
313
- try {
314
- const creds = accountToCreds(ctx.account);
315
- const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
316
-
317
- if (target.type === "c2c" || target.type === "group") {
318
- const r = await senderSendMedia({
319
- target,
320
- creds,
321
- kind: "image",
322
- source: { localPath: mediaPath },
323
- msgId: ctx.replyToId,
324
- localPathForMeta: mediaPath,
325
- });
326
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
327
- }
328
- debugLog(`sendPhoto: channel does not support local images`);
329
- return { channel: "qqbot", error: "Channel does not support local/Base64 images" };
330
- } catch (err) {
331
- if (err instanceof UploadDailyLimitExceededError) {
332
- debugError(`sendPhoto (local): daily upload quota exceeded`);
333
- return buildDailyLimitExceededResult(err);
334
- }
335
- const msg = formatErrorMessage(err);
336
- debugError(`sendPhoto (local) failed: ${msg}`);
337
- return { channel: "qqbot", error: msg };
338
- }
339
- }
340
-
341
- /**
342
- * Send voice from either a local file or a public URL.
343
- *
344
- * URL handling respects `urlDirectUpload`, and local files are transcoded when needed.
345
- */
346
- export async function sendVoice(
347
- ctx: MediaTargetContext,
348
- voicePath: string,
349
- directUploadFormats?: string[],
350
- transcodeEnabled: boolean = true,
351
- ): Promise<OutboundResult> {
352
- const resolvedMediaPath = resolveOutboundMediaPath(voicePath, "voice", {
353
- allowMissingLocalPath: true,
354
- });
355
- if (!resolvedMediaPath.ok) {
356
- return { channel: "qqbot", error: resolvedMediaPath.error };
357
- }
358
- const mediaPath = resolvedMediaPath.mediaPath;
359
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
360
-
361
- if (isHttp) {
362
- if (shouldDirectUploadUrl(ctx.account)) {
363
- try {
364
- const creds = accountToCreds(ctx.account);
365
- const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
366
- if (target.type === "c2c" || target.type === "group") {
367
- const r = await senderSendMedia({
368
- target,
369
- creds,
370
- kind: "voice",
371
- source: { url: mediaPath },
372
- msgId: ctx.replyToId,
373
- });
374
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
375
- }
376
- debugLog(`sendVoice: voice not supported in channel`);
377
- return { channel: "qqbot", error: "Voice not supported in channel" };
378
- } catch (err) {
379
- const msg = formatErrorMessage(err);
380
- debugWarn(
381
- `sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`,
382
- );
383
- }
384
- } else {
385
- debugLog(`sendVoice: urlDirectUpload=false, downloading URL first...`);
386
- }
387
-
388
- const localFile = await downloadToFallbackDir(mediaPath, "sendVoice");
389
- if (localFile) {
390
- return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled);
391
- }
392
- return { channel: "qqbot", error: `Failed to download audio: ${mediaPath.slice(0, 80)}` };
393
- }
394
-
395
- return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled);
396
- }
397
-
398
- /** Send voice from a local file. */
399
- async function sendVoiceFromLocal(
400
- ctx: MediaTargetContext,
401
- mediaPath: string,
402
- directUploadFormats: string[] | undefined,
403
- transcodeEnabled: boolean,
404
- ): Promise<OutboundResult> {
405
- // TTS can still be flushing the file to disk, so wait for a stable file first.
406
- const fileSize = await waitForFile(mediaPath);
407
- if (fileSize === 0) {
408
- return { channel: "qqbot", error: "Voice generate failed" };
409
- }
410
- if (fileSize > getMaxUploadSize(MediaFileType.VOICE)) {
411
- return buildFileTooLargeResult(MediaFileType.VOICE, fileSize);
412
- }
413
-
414
- // Re-check containment after the file appears to prevent symlink-race escapes.
415
- const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath);
416
- if (!safeMediaPath) {
417
- debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`);
418
- return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" };
419
- }
420
-
421
- const needsTranscode = shouldTranscodeVoice(safeMediaPath);
422
-
423
- if (needsTranscode && !transcodeEnabled) {
424
- const ext = normalizeLowercaseStringOrEmpty(path.extname(safeMediaPath));
425
- debugLog(
426
- `sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`,
427
- );
428
- return {
429
- channel: "qqbot",
430
- error: `Voice transcoding is disabled and format ${ext} cannot be uploaded directly`,
431
- };
432
- }
433
-
434
- try {
435
- const silkBase64 = await audioFileToSilkBase64(safeMediaPath, directUploadFormats);
436
- let uploadBase64 = silkBase64;
437
-
438
- if (!uploadBase64) {
439
- const buf = await readFileAsync(safeMediaPath);
440
- uploadBase64 = buf.toString("base64");
441
- debugLog(`sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`);
442
- } else {
443
- debugLog(`sendVoice: SILK ready (${fileSize} bytes)`);
444
- }
445
-
446
- const creds = accountToCreds(ctx.account);
447
- const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
448
-
449
- if (target.type === "c2c" || target.type === "group") {
450
- const r = await senderSendMedia({
451
- target,
452
- creds,
453
- kind: "voice",
454
- source: { base64: uploadBase64 },
455
- msgId: ctx.replyToId,
456
- localPathForMeta: safeMediaPath,
457
- });
458
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
459
- }
460
- debugLog(`sendVoice: voice not supported in channel`);
461
- return { channel: "qqbot", error: "Voice not supported in channel" };
462
- } catch (err) {
463
- if (err instanceof UploadDailyLimitExceededError) {
464
- debugError(`sendVoice (local): daily upload quota exceeded`);
465
- return buildDailyLimitExceededResult(err);
466
- }
467
- const msg = formatErrorMessage(err);
468
- debugError(`sendVoice (local) failed: ${msg}`);
469
- return { channel: "qqbot", error: msg };
470
- }
471
- }
472
-
473
- /** Send video from either a public URL or a local file. */
474
- export async function sendVideoMsg(
475
- ctx: MediaTargetContext,
476
- videoPath: string,
477
- ): Promise<OutboundResult> {
478
- const resolvedMediaPath = resolveOutboundMediaPath(videoPath, "video");
479
- if (!resolvedMediaPath.ok) {
480
- return { channel: "qqbot", error: resolvedMediaPath.error };
481
- }
482
- const mediaPath = resolvedMediaPath.mediaPath;
483
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
484
-
485
- if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
486
- debugLog(`sendVideoMsg: urlDirectUpload=false, downloading URL first...`);
487
- const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
488
- if (localFile) {
489
- return await sendVideoFromLocal(ctx, localFile);
490
- }
491
- return { channel: "qqbot", error: `Failed to download video: ${mediaPath.slice(0, 80)}` };
492
- }
493
-
494
- try {
495
- if (isHttp) {
496
- const creds = accountToCreds(ctx.account);
497
- const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
498
- if (target.type === "c2c" || target.type === "group") {
499
- const r = await senderSendMedia({
500
- target,
501
- creds,
502
- kind: "video",
503
- source: { url: mediaPath },
504
- msgId: ctx.replyToId,
505
- });
506
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
507
- }
508
- debugLog(`sendVideoMsg: video not supported in channel`);
509
- return { channel: "qqbot", error: "Video not supported in channel" };
510
- }
511
-
512
- return await sendVideoFromLocal(ctx, mediaPath);
513
- } catch (err) {
514
- const msg = formatErrorMessage(err);
515
-
516
- // If direct URL upload fails, retry through a local download path.
517
- if (isHttp) {
518
- debugWarn(
519
- `sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
520
- );
521
- const localFile = await downloadToFallbackDir(mediaPath, "sendVideoMsg");
522
- if (localFile) {
523
- return await sendVideoFromLocal(ctx, localFile);
524
- }
525
- }
526
-
527
- debugError(`sendVideoMsg failed: ${msg}`);
528
- return { channel: "qqbot", error: msg };
529
- }
530
- }
531
-
532
- /** Send video from a local file. */
533
- async function sendVideoFromLocal(
534
- ctx: MediaTargetContext,
535
- mediaPath: string,
536
- ): Promise<OutboundResult> {
537
- if (!(await fileExistsAsync(mediaPath))) {
538
- return { channel: "qqbot", error: "Video not found" };
539
- }
540
- const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.VIDEO));
541
- if (!sizeCheck.ok) {
542
- return buildFileTooLargeResult(MediaFileType.VIDEO, sizeCheck.size);
543
- }
544
- debugLog(`sendVideoMsg: local video (${formatFileSize(sizeCheck.size)})`);
545
-
546
- try {
547
- const creds = accountToCreds(ctx.account);
548
- const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
549
- if (target.type === "c2c" || target.type === "group") {
550
- const r = await senderSendMedia({
551
- target,
552
- creds,
553
- kind: "video",
554
- source: { localPath: mediaPath },
555
- msgId: ctx.replyToId,
556
- localPathForMeta: mediaPath,
557
- });
558
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
559
- }
560
- debugLog(`sendVideoMsg: video not supported in channel`);
561
- return { channel: "qqbot", error: "Video not supported in channel" };
562
- } catch (err) {
563
- if (err instanceof UploadDailyLimitExceededError) {
564
- debugError(`sendVideoMsg (local): daily upload quota exceeded`);
565
- return buildDailyLimitExceededResult(err);
566
- }
567
- const msg = formatErrorMessage(err);
568
- debugError(`sendVideoMsg (local) failed: ${msg}`);
569
- return { channel: "qqbot", error: msg };
570
- }
571
- }
572
-
573
- /** Send a file from a local path or public URL. */
574
- export async function sendDocument(
575
- ctx: MediaTargetContext,
576
- filePath: string,
577
- options: SendDocumentOptions = {},
578
- ): Promise<OutboundResult> {
579
- const extraLocalRoots = options.allowQQBotDataDownloads
580
- ? [getQQBotDataDir("downloads")]
581
- : undefined;
582
- const resolvedMediaPath = resolveOutboundMediaPath(filePath, "file", {
583
- extraLocalRoots,
584
- });
585
- if (!resolvedMediaPath.ok) {
586
- return { channel: "qqbot", error: resolvedMediaPath.error };
587
- }
588
- const mediaPath = resolvedMediaPath.mediaPath;
589
- const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
590
- const fileName = sanitizeFileName(path.basename(mediaPath));
591
-
592
- if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
593
- debugLog(`sendDocument: urlDirectUpload=false, downloading URL first...`);
594
- const localFile = await downloadToFallbackDir(mediaPath, "sendDocument");
595
- if (localFile) {
596
- return await sendDocumentFromLocal(ctx, localFile);
597
- }
598
- return { channel: "qqbot", error: `Failed to download file: ${mediaPath.slice(0, 80)}` };
599
- }
600
-
601
- try {
602
- if (isHttp) {
603
- const creds = accountToCreds(ctx.account);
604
- const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
605
- if (target.type === "c2c" || target.type === "group") {
606
- const r = await senderSendMedia({
607
- target,
608
- creds,
609
- kind: "file",
610
- source: { url: mediaPath },
611
- msgId: ctx.replyToId,
612
- fileName,
613
- });
614
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
615
- }
616
- debugLog(`sendDocument: file not supported in channel`);
617
- return { channel: "qqbot", error: "File not supported in channel" };
618
- }
619
-
620
- return await sendDocumentFromLocal(ctx, mediaPath);
621
- } catch (err) {
622
- const msg = formatErrorMessage(err);
623
-
624
- // If direct URL upload fails, retry through a local download path.
625
- if (isHttp) {
626
- debugWarn(
627
- `sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`,
628
- );
629
- const localFile = await downloadToFallbackDir(mediaPath, "sendDocument");
630
- if (localFile) {
631
- return await sendDocumentFromLocal(ctx, localFile);
632
- }
633
- }
634
-
635
- debugError(`sendDocument failed: ${msg}`);
636
- return { channel: "qqbot", error: msg };
637
- }
638
- }
639
-
640
- /** Send a file from local storage. */
641
- async function sendDocumentFromLocal(
642
- ctx: MediaTargetContext,
643
- mediaPath: string,
644
- ): Promise<OutboundResult> {
645
- const fileName = sanitizeFileName(path.basename(mediaPath));
646
-
647
- if (!(await fileExistsAsync(mediaPath))) {
648
- return { channel: "qqbot", error: "File not found" };
649
- }
650
- const sizeCheck = checkFileSize(mediaPath, getMaxUploadSize(MediaFileType.FILE));
651
- if (!sizeCheck.ok) {
652
- return buildFileTooLargeResult(MediaFileType.FILE, sizeCheck.size);
653
- }
654
- if (sizeCheck.size === 0) {
655
- return { channel: "qqbot", error: `File is empty: ${mediaPath}` };
656
- }
657
- debugLog(`sendDocument: local file (${formatFileSize(sizeCheck.size)})`);
658
-
659
- try {
660
- const creds = accountToCreds(ctx.account);
661
- const target: DeliveryTarget = { type: ctx.targetType, id: ctx.targetId };
662
- if (target.type === "c2c" || target.type === "group") {
663
- const r = await senderSendMedia({
664
- target,
665
- creds,
666
- kind: "file",
667
- source: { localPath: mediaPath },
668
- msgId: ctx.replyToId,
669
- fileName,
670
- localPathForMeta: mediaPath,
671
- });
672
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
673
- }
674
- debugLog(`sendDocument: file not supported in channel`);
675
- return { channel: "qqbot", error: "File not supported in channel" };
676
- } catch (err) {
677
- if (err instanceof UploadDailyLimitExceededError) {
678
- debugError(`sendDocument (local): daily upload quota exceeded`);
679
- return buildDailyLimitExceededResult(err);
680
- }
681
- const msg = formatErrorMessage(err);
682
- debugError(`sendDocument (local) failed: ${msg}`);
683
- return { channel: "qqbot", error: msg };
684
- }
685
- }
686
-
687
- /** Download a remote file into the fallback media directory. */
688
- async function downloadToFallbackDir(httpUrl: string, caller: string): Promise<string | null> {
689
- try {
690
- const downloadDir = getQQBotMediaDir("downloads", "url-fallback");
691
- const localFile = await downloadFile(httpUrl, downloadDir);
692
- if (!localFile) {
693
- debugError(`${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`);
694
- return null;
695
- }
696
- debugLog(`${caller} fallback: downloaded → ${localFile}`);
697
- return localFile;
698
- } catch (err) {
699
- debugError(`${caller} fallback download error:`, err);
700
- return null;
701
- }
702
- }