@tencent-connect/openclaw-qqbot 1.5.7 → 1.6.0-alpha.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 (63) hide show
  1. package/README.md +9 -2
  2. package/README.zh.md +7 -2
  3. package/package.json +1 -1
  4. package/scripts/upgrade-via-npm.sh +85 -115
  5. package/scripts/upgrade-via-source.sh +203 -35
  6. package/skills/qqbot-cron/SKILL.md +46 -423
  7. package/skills/qqbot-media/SKILL.md +29 -182
  8. package/src/api.ts +16 -5
  9. package/src/channel.ts +6 -7
  10. package/src/gateway.ts +510 -525
  11. package/src/image-server.ts +72 -10
  12. package/src/openclaw-plugin-sdk.d.ts +1 -1
  13. package/src/outbound.ts +571 -611
  14. package/src/ref-index-store.ts +1 -1
  15. package/src/slash-commands.ts +425 -0
  16. package/src/types.ts +18 -1
  17. package/src/update-checker.ts +102 -0
  18. package/src/user-messages.ts +73 -0
  19. package/src/utils/audio-convert.ts +69 -4
  20. package/src/utils/media-tags.ts +46 -4
  21. package/dist/AI/345/210/233/346/226/260/345/272/224/347/224/250/345/245/226_/347/224/263/346/212/245/344/271/246.md +0 -211
  22. package/dist/index.d.ts +0 -17
  23. package/dist/index.js +0 -22
  24. package/dist/src/api.d.ts +0 -138
  25. package/dist/src/api.js +0 -525
  26. package/dist/src/channel.d.ts +0 -3
  27. package/dist/src/channel.js +0 -337
  28. package/dist/src/config.d.ts +0 -25
  29. package/dist/src/config.js +0 -161
  30. package/dist/src/gateway.d.ts +0 -18
  31. package/dist/src/gateway.js +0 -2468
  32. package/dist/src/image-server.d.ts +0 -62
  33. package/dist/src/image-server.js +0 -401
  34. package/dist/src/known-users.d.ts +0 -100
  35. package/dist/src/known-users.js +0 -263
  36. package/dist/src/onboarding.d.ts +0 -10
  37. package/dist/src/onboarding.js +0 -203
  38. package/dist/src/outbound.d.ts +0 -150
  39. package/dist/src/outbound.js +0 -1175
  40. package/dist/src/proactive.d.ts +0 -170
  41. package/dist/src/proactive.js +0 -399
  42. package/dist/src/runtime.d.ts +0 -3
  43. package/dist/src/runtime.js +0 -10
  44. package/dist/src/session-store.d.ts +0 -52
  45. package/dist/src/session-store.js +0 -254
  46. package/dist/src/slash-commands.d.ts +0 -48
  47. package/dist/src/slash-commands.js +0 -212
  48. package/dist/src/types.d.ts +0 -146
  49. package/dist/src/types.js +0 -1
  50. package/dist/src/utils/audio-convert.d.ts +0 -73
  51. package/dist/src/utils/audio-convert.js +0 -645
  52. package/dist/src/utils/file-utils.d.ts +0 -46
  53. package/dist/src/utils/file-utils.js +0 -107
  54. package/dist/src/utils/image-size.d.ts +0 -51
  55. package/dist/src/utils/image-size.js +0 -234
  56. package/dist/src/utils/media-tags.d.ts +0 -14
  57. package/dist/src/utils/media-tags.js +0 -120
  58. package/dist/src/utils/payload.d.ts +0 -112
  59. package/dist/src/utils/payload.js +0 -186
  60. package/dist/src/utils/platform.d.ts +0 -126
  61. package/dist/src/utils/platform.js +0 -358
  62. package/dist/src/utils/upload-cache.d.ts +0 -34
  63. package/dist/src/utils/upload-cache.js +0 -93
package/src/outbound.ts CHANGED
@@ -21,10 +21,12 @@ import {
21
21
  sendC2CFileMessage,
22
22
  sendGroupFileMessage,
23
23
  } from "./api.js";
24
- import { isAudioFile, audioFileToSilkBase64, waitForFile } from "./utils/audio-convert.js";
24
+ import { isAudioFile, audioFileToSilkBase64, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
25
25
  import { normalizeMediaTags } from "./utils/media-tags.js";
26
26
  import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
27
- import { isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName } from "./utils/platform.js";
27
+ import { isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, getQQBotDataDir } from "./utils/platform.js";
28
+ import { downloadFile } from "./image-server.js";
29
+ import { MSG } from "./user-messages.js";
28
30
 
29
31
  // ============ 消息回复限流器 ============
30
32
  // 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
@@ -163,6 +165,8 @@ export interface OutboundContext {
163
165
 
164
166
  export interface MediaOutboundContext extends OutboundContext {
165
167
  mediaUrl: string;
168
+ /** 可选的 MIME 类型,优先于扩展名判断媒体类型 */
169
+ mimeType?: string;
166
170
  }
167
171
 
168
172
  export interface OutboundResult {
@@ -233,6 +237,486 @@ function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: strin
233
237
  return { type: "c2c", id };
234
238
  }
235
239
 
240
+ // ============ Telegram 风格的结构化媒体发送接口 ============
241
+ // 类似 Telegram 的 sendPhoto / sendVoice / sendVideo / sendDocument,
242
+ // 每种媒体类型一个独立函数,接收结构化参数,无需标签解析。
243
+ // gateway.ts 的 deliver 回调和 sendText 共用这些函数,消除重复代码。
244
+
245
+ /** 媒体发送的目标上下文(从 deliver 回调或 sendText 中提取) */
246
+ export interface MediaTargetContext {
247
+ /** 目标类型 */
248
+ targetType: "c2c" | "group" | "channel";
249
+ /** 目标 ID */
250
+ targetId: string;
251
+ /** QQ Bot 账户配置 */
252
+ account: ResolvedQQBotAccount;
253
+ /** 被动回复消息 ID(可选) */
254
+ replyToId?: string;
255
+ /** 日志前缀(可选,用于区分调用来源) */
256
+ logPrefix?: string;
257
+ }
258
+
259
+ /** 从 OutboundContext 构建 MediaTargetContext */
260
+ function buildMediaTarget(ctx: { to: string; account: ResolvedQQBotAccount; replyToId?: string | null }, logPrefix?: string): MediaTargetContext {
261
+ const target = parseTarget(ctx.to);
262
+ return {
263
+ targetType: target.type,
264
+ targetId: target.id,
265
+ account: ctx.account,
266
+ replyToId: ctx.replyToId ?? undefined,
267
+ logPrefix,
268
+ };
269
+ }
270
+
271
+ /** 获取已认证的 access token,失败时抛出异常 */
272
+ async function getToken(account: ResolvedQQBotAccount): Promise<string> {
273
+ if (!account.appId || !account.clientSecret) {
274
+ throw new Error("QQBot not configured (missing appId or clientSecret)");
275
+ }
276
+ return getAccessToken(account.appId, account.clientSecret);
277
+ }
278
+
279
+ /** 判断是否应该对公网 URL 执行直传(不下载) */
280
+ function shouldDirectUploadUrl(account: ResolvedQQBotAccount): boolean {
281
+ return account.config?.urlDirectUpload !== false; // 默认 true
282
+ }
283
+
284
+ /**
285
+ * sendPhoto — 发送图片消息(对齐 Telegram sendPhoto)
286
+ *
287
+ * 支持三种来源:
288
+ * - 本地文件路径(自动读取转 Base64)
289
+ * - 公网 HTTP/HTTPS URL(urlDirectUpload=true 时先直传平台,失败自动下载重试;=false 时直接下载)
290
+ * - Base64 Data URL
291
+ */
292
+ export async function sendPhoto(
293
+ ctx: MediaTargetContext,
294
+ imagePath: string,
295
+ ): Promise<OutboundResult> {
296
+ const prefix = ctx.logPrefix ?? "[qqbot]";
297
+ const mediaPath = normalizePath(imagePath);
298
+ const isLocal = isLocalFilePath(mediaPath);
299
+ const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
300
+ const isData = mediaPath.startsWith("data:");
301
+
302
+ // urlDirectUpload=false 时,公网 URL 直接下载到本地再发送
303
+ if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
304
+ console.log(`${prefix} sendPhoto: urlDirectUpload=false, downloading URL first...`);
305
+ const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendPhoto");
306
+ if (localFile) {
307
+ return await sendPhoto(ctx, localFile);
308
+ }
309
+ return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
310
+ }
311
+
312
+ let imageUrl = mediaPath;
313
+
314
+ if (isLocal) {
315
+ if (!(await fileExistsAsync(mediaPath))) {
316
+ return { channel: "qqbot", error: MSG.IMAGE_NOT_FOUND };
317
+ }
318
+ const sizeCheck = checkFileSize(mediaPath);
319
+ if (!sizeCheck.ok) {
320
+ return { channel: "qqbot", error: sizeCheck.error! };
321
+ }
322
+ const fileBuffer = await readFileAsync(mediaPath);
323
+ const ext = path.extname(mediaPath).toLowerCase();
324
+ const mimeTypes: Record<string, string> = {
325
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
326
+ ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
327
+ };
328
+ const mimeType = mimeTypes[ext];
329
+ if (!mimeType) {
330
+ return { channel: "qqbot", error: MSG.IMAGE_FORMAT_UNSUPPORTED(ext) };
331
+ }
332
+ imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
333
+ console.log(`${prefix} sendPhoto: local → Base64 (${formatFileSize(fileBuffer.length)})`);
334
+ } else if (!isHttp && !isData) {
335
+ return { channel: "qqbot", error: `不支持的图片来源: ${mediaPath.slice(0, 50)}` };
336
+ }
337
+
338
+ try {
339
+ const token = await getToken(ctx.account);
340
+ const localPath = isLocal ? mediaPath : undefined;
341
+
342
+ if (ctx.targetType === "c2c") {
343
+ const r = await sendC2CImageMessage(token, ctx.targetId, imageUrl, ctx.replyToId, undefined, localPath);
344
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
345
+ } else if (ctx.targetType === "group") {
346
+ const r = await sendGroupImageMessage(token, ctx.targetId, imageUrl, ctx.replyToId);
347
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
348
+ } else {
349
+ // 频道:仅支持公网 URL(Markdown 格式)
350
+ if (isHttp) {
351
+ const r = await sendChannelMessage(token, ctx.targetId, `![](${mediaPath})`, ctx.replyToId);
352
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
353
+ }
354
+ console.log(`${prefix} sendPhoto: channel does not support local/Base64 images`);
355
+ return { channel: "qqbot" };
356
+ }
357
+ } catch (err) {
358
+ const msg = err instanceof Error ? err.message : String(err);
359
+
360
+ // 公网 URL 直传失败(如 QQ 平台拉取海外域名超时/被墙)→ 插件自己下载 → Base64 重试
361
+ if (isHttp && !isData) {
362
+ console.warn(`${prefix} sendPhoto: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
363
+ const retryResult = await downloadAndRetrySendPhoto(ctx, mediaPath, prefix);
364
+ if (retryResult) return retryResult;
365
+ }
366
+
367
+ console.error(`${prefix} sendPhoto failed: ${msg}`);
368
+ return { channel: "qqbot", error: msg };
369
+ }
370
+ }
371
+
372
+ /**
373
+ * sendPhoto 的 URL fallback:下载远程图片到本地 → 转 Base64 → 重试发送
374
+ * 解决 QQ 开放平台无法拉取某些公网 URL(如海外域名)的问题
375
+ */
376
+ async function downloadAndRetrySendPhoto(
377
+ ctx: MediaTargetContext,
378
+ httpUrl: string,
379
+ prefix: string,
380
+ ): Promise<OutboundResult | null> {
381
+ try {
382
+ const downloadDir = getQQBotDataDir("downloads", "url-fallback");
383
+ const localFile = await downloadFile(httpUrl, downloadDir);
384
+ if (!localFile) {
385
+ console.error(`${prefix} sendPhoto fallback: download also failed for ${httpUrl.slice(0, 80)}`);
386
+ return null;
387
+ }
388
+
389
+ console.log(`${prefix} sendPhoto fallback: downloaded → ${localFile}, retrying as Base64`);
390
+ // 递归调用 sendPhoto,此时走本地文件路径
391
+ return await sendPhoto(ctx, localFile);
392
+ } catch (err) {
393
+ console.error(`${prefix} sendPhoto fallback error:`, err);
394
+ return null;
395
+ }
396
+ }
397
+
398
+ /**
399
+ * sendVoice — 发送语音消息(对齐 Telegram sendVoice)
400
+ *
401
+ * 支持本地音频文件和公网 URL:
402
+ * - urlDirectUpload=true + 公网URL:先直传平台,失败后下载到本地再转码重试
403
+ * - urlDirectUpload=false + 公网URL:直接下载到本地再转码发送
404
+ * - 本地文件:自动转换为 SILK 格式后上传
405
+ *
406
+ * 支持 transcodeEnabled 配置:禁用时非原生格式 fallback 到文件发送。
407
+ */
408
+ export async function sendVoice(
409
+ ctx: MediaTargetContext,
410
+ voicePath: string,
411
+ /** 直传格式列表(跳过 SILK 转换),可选 */
412
+ directUploadFormats?: string[],
413
+ /** 是否启用转码(默认 true),false 时非原生格式直接返回错误 */
414
+ transcodeEnabled: boolean = true,
415
+ ): Promise<OutboundResult> {
416
+ const prefix = ctx.logPrefix ?? "[qqbot]";
417
+ const mediaPath = normalizePath(voicePath);
418
+ const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
419
+
420
+ // 公网 URL 处理
421
+ if (isHttp) {
422
+ // urlDirectUpload=true: 先尝试直传平台
423
+ if (shouldDirectUploadUrl(ctx.account)) {
424
+ try {
425
+ const token = await getToken(ctx.account);
426
+ if (ctx.targetType === "c2c") {
427
+ const r = await sendC2CVoiceMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId);
428
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
429
+ } else if (ctx.targetType === "group") {
430
+ const r = await sendGroupVoiceMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId);
431
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
432
+ } else {
433
+ const r = await sendChannelMessage(token, ctx.targetId, MSG.VOICE_CHANNEL_UNSUPPORTED, ctx.replyToId);
434
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
435
+ }
436
+ } catch (err) {
437
+ const msg = err instanceof Error ? err.message : String(err);
438
+ console.warn(`${prefix} sendVoice: URL direct upload failed (${msg}), downloading locally and retrying...`);
439
+ }
440
+ } else {
441
+ console.log(`${prefix} sendVoice: urlDirectUpload=false, downloading URL first...`);
442
+ }
443
+
444
+ // 下载到本地,然后走本地文件路径(含转码)
445
+ const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVoice");
446
+ if (localFile) {
447
+ return await sendVoiceFromLocal(ctx, localFile, directUploadFormats, transcodeEnabled, prefix);
448
+ }
449
+ return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
450
+ }
451
+
452
+ // 本地文件
453
+ return await sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcodeEnabled, prefix);
454
+ }
455
+
456
+ /** 从本地文件发送语音(sendVoice 的内部辅助) */
457
+ async function sendVoiceFromLocal(
458
+ ctx: MediaTargetContext,
459
+ mediaPath: string,
460
+ directUploadFormats: string[] | undefined,
461
+ transcodeEnabled: boolean,
462
+ prefix: string,
463
+ ): Promise<OutboundResult> {
464
+ // 等待文件就绪(TTS 异步生成,文件可能还没写完)
465
+ const fileSize = await waitForFile(mediaPath);
466
+ if (fileSize === 0) {
467
+ return { channel: "qqbot", error: MSG.VOICE_GENERATE_FAILED };
468
+ }
469
+
470
+ // 精细检测:是否需要转码
471
+ const needsTranscode = shouldTranscodeVoice(mediaPath);
472
+
473
+ // 转码已禁用但需要转码 → 提前 fallback
474
+ if (needsTranscode && !transcodeEnabled) {
475
+ const ext = path.extname(mediaPath).toLowerCase();
476
+ console.log(`${prefix} sendVoice: transcode disabled, format ${ext} needs transcode, returning error for fallback`);
477
+ return { channel: "qqbot", error: `语音转码已禁用,格式 ${ext} 不支持直传` };
478
+ }
479
+
480
+ try {
481
+ const silkBase64 = await audioFileToSilkBase64(mediaPath, directUploadFormats);
482
+ let uploadBase64 = silkBase64;
483
+
484
+ if (!uploadBase64) {
485
+ const buf = await readFileAsync(mediaPath);
486
+ uploadBase64 = buf.toString("base64");
487
+ console.log(`${prefix} sendVoice: SILK conversion failed, uploading raw (${formatFileSize(buf.length)})`);
488
+ } else {
489
+ console.log(`${prefix} sendVoice: SILK ready (${fileSize} bytes)`);
490
+ }
491
+
492
+ const token = await getToken(ctx.account);
493
+
494
+ if (ctx.targetType === "c2c") {
495
+ const r = await sendC2CVoiceMessage(token, ctx.targetId, uploadBase64, undefined, ctx.replyToId, undefined, mediaPath);
496
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
497
+ } else if (ctx.targetType === "group") {
498
+ const r = await sendGroupVoiceMessage(token, ctx.targetId, uploadBase64, undefined, ctx.replyToId);
499
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
500
+ } else {
501
+ const r = await sendChannelMessage(token, ctx.targetId, MSG.VOICE_CHANNEL_UNSUPPORTED, ctx.replyToId);
502
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
503
+ }
504
+ } catch (err) {
505
+ const msg = err instanceof Error ? err.message : String(err);
506
+ console.error(`${prefix} sendVoice (local) failed: ${msg}`);
507
+ return { channel: "qqbot", error: msg };
508
+ }
509
+ }
510
+
511
+ /**
512
+ * sendVideoMsg — 发送视频消息(对齐 Telegram sendVideo)
513
+ *
514
+ * 支持公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)和本地文件路径。
515
+ */
516
+ export async function sendVideoMsg(
517
+ ctx: MediaTargetContext,
518
+ videoPath: string,
519
+ ): Promise<OutboundResult> {
520
+ const prefix = ctx.logPrefix ?? "[qqbot]";
521
+ const mediaPath = normalizePath(videoPath);
522
+ const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
523
+
524
+ // urlDirectUpload=false 时,公网 URL 直接下载到本地再发送
525
+ if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
526
+ console.log(`${prefix} sendVideoMsg: urlDirectUpload=false, downloading URL first...`);
527
+ const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg");
528
+ if (localFile) {
529
+ return await sendVideoFromLocal(ctx, localFile, prefix);
530
+ }
531
+ return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
532
+ }
533
+
534
+ try {
535
+ const token = await getToken(ctx.account);
536
+
537
+ if (isHttp) {
538
+ // 公网 URL:先尝试直传平台
539
+ if (ctx.targetType === "c2c") {
540
+ const r = await sendC2CVideoMessage(token, ctx.targetId, mediaPath, undefined, ctx.replyToId);
541
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
542
+ } else if (ctx.targetType === "group") {
543
+ const r = await sendGroupVideoMessage(token, ctx.targetId, mediaPath, undefined, ctx.replyToId);
544
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
545
+ } else {
546
+ const r = await sendChannelMessage(token, ctx.targetId, MSG.VIDEO_CHANNEL_UNSUPPORTED, ctx.replyToId);
547
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
548
+ }
549
+ }
550
+
551
+ // 本地文件
552
+ return await sendVideoFromLocal(ctx, mediaPath, prefix);
553
+ } catch (err) {
554
+ const msg = err instanceof Error ? err.message : String(err);
555
+
556
+ // 公网 URL 直传失败 → 插件下载 → Base64 重试
557
+ if (isHttp) {
558
+ console.warn(`${prefix} sendVideoMsg: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
559
+ const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendVideoMsg");
560
+ if (localFile) {
561
+ return await sendVideoFromLocal(ctx, localFile, prefix);
562
+ }
563
+ }
564
+
565
+ console.error(`${prefix} sendVideoMsg failed: ${msg}`);
566
+ return { channel: "qqbot", error: msg };
567
+ }
568
+ }
569
+
570
+ /** 从本地文件发送视频(sendVideoMsg 的内部辅助) */
571
+ async function sendVideoFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string): Promise<OutboundResult> {
572
+ if (!(await fileExistsAsync(mediaPath))) {
573
+ return { channel: "qqbot", error: MSG.VIDEO_NOT_FOUND };
574
+ }
575
+ const sizeCheck = checkFileSize(mediaPath);
576
+ if (!sizeCheck.ok) {
577
+ return { channel: "qqbot", error: sizeCheck.error! };
578
+ }
579
+
580
+ const fileBuffer = await readFileAsync(mediaPath);
581
+ const videoBase64 = fileBuffer.toString("base64");
582
+ console.log(`${prefix} sendVideoMsg: local video (${formatFileSize(fileBuffer.length)})`);
583
+
584
+ try {
585
+ const token = await getToken(ctx.account);
586
+ if (ctx.targetType === "c2c") {
587
+ const r = await sendC2CVideoMessage(token, ctx.targetId, undefined, videoBase64, ctx.replyToId, undefined, mediaPath);
588
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
589
+ } else if (ctx.targetType === "group") {
590
+ const r = await sendGroupVideoMessage(token, ctx.targetId, undefined, videoBase64, ctx.replyToId);
591
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
592
+ } else {
593
+ const r = await sendChannelMessage(token, ctx.targetId, MSG.VIDEO_CHANNEL_UNSUPPORTED, ctx.replyToId);
594
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
595
+ }
596
+ } catch (err) {
597
+ const msg = err instanceof Error ? err.message : String(err);
598
+ console.error(`${prefix} sendVideoMsg (local) failed: ${msg}`);
599
+ return { channel: "qqbot", error: msg };
600
+ }
601
+ }
602
+
603
+ /**
604
+ * sendDocument — 发送文件消息(对齐 Telegram sendDocument)
605
+ *
606
+ * 支持本地文件路径和公网 URL(urlDirectUpload 控制直传或下载,失败自动 fallback)。
607
+ */
608
+ export async function sendDocument(
609
+ ctx: MediaTargetContext,
610
+ filePath: string,
611
+ ): Promise<OutboundResult> {
612
+ const prefix = ctx.logPrefix ?? "[qqbot]";
613
+ const mediaPath = normalizePath(filePath);
614
+ const isHttp = mediaPath.startsWith("http://") || mediaPath.startsWith("https://");
615
+ const fileName = sanitizeFileName(path.basename(mediaPath));
616
+
617
+ // urlDirectUpload=false 时,公网 URL 直接下载到本地再发送
618
+ if (isHttp && !shouldDirectUploadUrl(ctx.account)) {
619
+ console.log(`${prefix} sendDocument: urlDirectUpload=false, downloading URL first...`);
620
+ const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument");
621
+ if (localFile) {
622
+ return await sendDocumentFromLocal(ctx, localFile, prefix);
623
+ }
624
+ return { channel: "qqbot", error: `下载失败: ${mediaPath.slice(0, 80)}` };
625
+ }
626
+
627
+ try {
628
+ const token = await getToken(ctx.account);
629
+
630
+ if (isHttp) {
631
+ // 公网 URL:先尝试直传平台
632
+ if (ctx.targetType === "c2c") {
633
+ const r = await sendC2CFileMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId, fileName);
634
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
635
+ } else if (ctx.targetType === "group") {
636
+ const r = await sendGroupFileMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId, fileName);
637
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
638
+ } else {
639
+ const r = await sendChannelMessage(token, ctx.targetId, MSG.FILE_CHANNEL_UNSUPPORTED, ctx.replyToId);
640
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
641
+ }
642
+ }
643
+
644
+ // 本地文件
645
+ return await sendDocumentFromLocal(ctx, mediaPath, prefix);
646
+ } catch (err) {
647
+ const msg = err instanceof Error ? err.message : String(err);
648
+
649
+ // 公网 URL 直传失败 → 插件下载 → Base64 重试
650
+ if (isHttp) {
651
+ console.warn(`${prefix} sendDocument: URL direct upload failed (${msg}), downloading locally and retrying as Base64...`);
652
+ const localFile = await downloadToFallbackDir(mediaPath, prefix, "sendDocument");
653
+ if (localFile) {
654
+ return await sendDocumentFromLocal(ctx, localFile, prefix);
655
+ }
656
+ }
657
+
658
+ console.error(`${prefix} sendDocument failed: ${msg}`);
659
+ return { channel: "qqbot", error: msg };
660
+ }
661
+ }
662
+
663
+ /** 从本地文件发送文件(sendDocument 的内部辅助) */
664
+ async function sendDocumentFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string): Promise<OutboundResult> {
665
+ const fileName = sanitizeFileName(path.basename(mediaPath));
666
+
667
+ if (!(await fileExistsAsync(mediaPath))) {
668
+ return { channel: "qqbot", error: MSG.FILE_NOT_FOUND };
669
+ }
670
+ const sizeCheck = checkFileSize(mediaPath);
671
+ if (!sizeCheck.ok) {
672
+ return { channel: "qqbot", error: sizeCheck.error! };
673
+ }
674
+ const fileBuffer = await readFileAsync(mediaPath);
675
+ if (fileBuffer.length === 0) {
676
+ return { channel: "qqbot", error: `文件内容为空: ${mediaPath}` };
677
+ }
678
+ const fileBase64 = fileBuffer.toString("base64");
679
+ console.log(`${prefix} sendDocument: local file (${formatFileSize(fileBuffer.length)})`);
680
+
681
+ try {
682
+ const token = await getToken(ctx.account);
683
+ if (ctx.targetType === "c2c") {
684
+ const r = await sendC2CFileMessage(token, ctx.targetId, fileBase64, undefined, ctx.replyToId, fileName, mediaPath);
685
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
686
+ } else if (ctx.targetType === "group") {
687
+ const r = await sendGroupFileMessage(token, ctx.targetId, fileBase64, undefined, ctx.replyToId, fileName);
688
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
689
+ } else {
690
+ const r = await sendChannelMessage(token, ctx.targetId, MSG.FILE_CHANNEL_UNSUPPORTED, ctx.replyToId);
691
+ return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
692
+ }
693
+ } catch (err) {
694
+ const msg = err instanceof Error ? err.message : String(err);
695
+ console.error(`${prefix} sendDocument (local) failed: ${msg}`);
696
+ return { channel: "qqbot", error: msg };
697
+ }
698
+ }
699
+
700
+ /**
701
+ * 通用辅助:下载远程文件到 fallback 目录
702
+ * 用于各 send* 函数的 URL 直传失败 fallback
703
+ */
704
+ async function downloadToFallbackDir(httpUrl: string, prefix: string, caller: string): Promise<string | null> {
705
+ try {
706
+ const downloadDir = getQQBotDataDir("downloads", "url-fallback");
707
+ const localFile = await downloadFile(httpUrl, downloadDir);
708
+ if (!localFile) {
709
+ console.error(`${prefix} ${caller} fallback: download also failed for ${httpUrl.slice(0, 80)}`);
710
+ return null;
711
+ }
712
+ console.log(`${prefix} ${caller} fallback: downloaded → ${localFile}`);
713
+ return localFile;
714
+ } catch (err) {
715
+ console.error(`${prefix} ${caller} fallback download error:`, err);
716
+ return null;
717
+ }
718
+ }
719
+
236
720
  /**
237
721
  * 发送文本消息
238
722
  * - 有 replyToId: 被动回复,1小时内最多回复4次
@@ -275,26 +759,27 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
275
759
  }
276
760
 
277
761
  // ============ 媒体标签检测与处理 ============
278
- // 支持四种标签:
279
- // <qqimg>路径</qqimg> 或 <qqimg>路径</img> — 图片
280
- // <qqvoice>路径</qqvoice> — 语音
281
- // <qqvideo>路径或URL</qqvideo> — 视频
282
- // <qqfile>路径</qqfile> — 文件
762
+ // 支持五种标签:
763
+ // <qqimg>路径</qqimg> — 图片
764
+ // <qqvoice>路径</qqvoice> — 语音
765
+ // <qqvideo>路径或URL</qqvideo> — 视频
766
+ // <qqfile>路径</qqfile> — 文件
767
+ // <qqmedia>路径或URL</qqmedia> — 自动识别(根据扩展名路由)
283
768
 
284
769
  // 预处理:纠正小模型常见的标签拼写错误和格式问题
285
770
  text = normalizeMediaTags(text);
286
771
 
287
- const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
772
+ const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
288
773
  const mediaTagMatches = text.match(mediaTagRegex);
289
774
 
290
775
  if (mediaTagMatches && mediaTagMatches.length > 0) {
291
776
  console.log(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`);
292
777
 
293
778
  // 构建发送队列:根据内容在原文中的实际位置顺序发送
294
- const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file"; content: string }> = [];
779
+ const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string }> = [];
295
780
 
296
781
  let lastIndex = 0;
297
- const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
782
+ const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
298
783
  let match;
299
784
 
300
785
  while ((match = mediaTagRegexWithIndex.exec(text)) !== null) {
@@ -356,7 +841,10 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
356
841
  }
357
842
 
358
843
  if (mediaPath) {
359
- if (tagName === "qqvoice") {
844
+ if (tagName === "qqmedia") {
845
+ sendQueue.push({ type: "media", content: mediaPath });
846
+ console.log(`[qqbot] sendText: Found auto-detect media in <qqmedia>: ${mediaPath}`);
847
+ } else if (tagName === "qqvoice") {
360
848
  sendQueue.push({ type: "voice", content: mediaPath });
361
849
  console.log(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
362
850
  } else if (tagName === "qqvideo") {
@@ -382,13 +870,8 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
382
870
 
383
871
  console.log(`[qqbot] sendText: Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
384
872
 
385
- // 按顺序发送
386
- if (!account.appId || !account.clientSecret) {
387
- return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
388
- }
389
-
390
- const accessToken = await getAccessToken(account.appId, account.clientSecret);
391
- const target = parseTarget(to);
873
+ // 按顺序发送(使用 Telegram 风格的统一媒体发送函数)
874
+ const mediaTarget = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendText]");
392
875
  let lastResult: OutboundResult = { channel: "qqbot" };
393
876
 
394
877
  for (const item of sendQueue) {
@@ -396,7 +879,8 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
396
879
  if (item.type === "text") {
397
880
  // 发送文本
398
881
  if (replyToId) {
399
- // 被动回复
882
+ const accessToken = await getToken(account);
883
+ const target = parseTarget(to);
400
884
  if (target.type === "c2c") {
401
885
  const result = await sendC2CMessage(accessToken, target.id, item.content, replyToId);
402
886
  recordMessageReply(replyToId);
@@ -411,7 +895,8 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
411
895
  lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
412
896
  }
413
897
  } else {
414
- // 主动消息
898
+ const accessToken = await getToken(account);
899
+ const target = parseTarget(to);
415
900
  if (target.type === "c2c") {
416
901
  const result = await sendProactiveC2CMessage(accessToken, target.id, item.content);
417
902
  lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp, refIdx: (result as any).ext_info?.ref_idx };
@@ -425,214 +910,23 @@ export async function sendText(ctx: OutboundContext): Promise<OutboundResult> {
425
910
  }
426
911
  console.log(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
427
912
  } else if (item.type === "image") {
428
- // 发送图片
429
- const imagePath = item.content;
430
- const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://");
431
-
432
- let imageUrl = imagePath;
433
-
434
- // 如果是本地文件路径,读取并转换为 Base64
435
- if (!isHttpUrl && !imagePath.startsWith("data:")) {
436
- if (!(await fileExistsAsync(imagePath))) {
437
- console.error(`[qqbot] sendText: Image file not found: ${imagePath}`);
438
- continue;
439
- }
440
- // 文件大小校验
441
- const sizeCheck = checkFileSize(imagePath);
442
- if (!sizeCheck.ok) {
443
- console.error(`[qqbot] sendText: ${sizeCheck.error}`);
444
- continue;
445
- }
446
- const fileBuffer = await readFileAsync(imagePath);
447
- const ext = path.extname(imagePath).toLowerCase();
448
- const mimeTypes: Record<string, string> = {
449
- ".jpg": "image/jpeg",
450
- ".jpeg": "image/jpeg",
451
- ".png": "image/png",
452
- ".gif": "image/gif",
453
- ".webp": "image/webp",
454
- ".bmp": "image/bmp",
455
- };
456
- const mimeType = mimeTypes[ext] ?? "image/png";
457
- imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
458
- console.log(`[qqbot] sendText: Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
459
- }
460
-
461
- // 发送图片
462
- if (target.type === "c2c") {
463
- const result = await sendC2CImageMessage(accessToken, target.id, imageUrl, replyToId ?? undefined, undefined, isHttpUrl ? undefined : imagePath);
464
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
465
- } else if (target.type === "group") {
466
- const result = await sendGroupImageMessage(accessToken, target.id, imageUrl, replyToId ?? undefined);
467
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
468
- } else if (isHttpUrl) {
469
- // 频道使用 Markdown 格式(仅支持公网 URL)
470
- const result = await sendChannelMessage(accessToken, target.id, `![](${imagePath})`, replyToId ?? undefined);
471
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
472
- }
473
- console.log(`[qqbot] sendText: Sent image via <qqimg> tag: ${imagePath.slice(0, 60)}...`);
913
+ lastResult = await sendPhoto(mediaTarget, item.content);
474
914
  } else if (item.type === "voice") {
475
- // 发送语音文件
476
- const voicePath = item.content;
477
-
478
- // 等待文件就绪(TTS 工具异步生成,文件可能还没写完)
479
- const fileSize = await waitForFile(voicePath);
480
- if (fileSize === 0) {
481
- console.error(`[qqbot] sendText: Voice file not ready after waiting: ${voicePath}`);
482
- // 发送友好提示给用户
483
- try {
484
- if (target.type === "c2c") {
485
- await sendC2CMessage(accessToken, target.id, "语音生成失败,请稍后重试", replyToId ?? undefined);
486
- } else if (target.type === "group") {
487
- await sendGroupMessage(accessToken, target.id, "语音生成失败,请稍后重试", replyToId ?? undefined);
488
- }
489
- } catch {}
490
- continue;
491
- }
492
-
493
- // 转换为 SILK 格式(QQ Bot API 语音只支持 SILK)
494
- const silkBase64 = await audioFileToSilkBase64(voicePath);
495
- if (!silkBase64) {
496
- const ext = path.extname(voicePath).toLowerCase();
497
- console.error(`[qqbot] sendText: Voice conversion to SILK failed: ${ext} (${fileSize} bytes)`);
498
- try {
499
- if (target.type === "c2c") {
500
- await sendC2CMessage(accessToken, target.id, "语音格式转换失败,请稍后重试", replyToId ?? undefined);
501
- } else if (target.type === "group") {
502
- await sendGroupMessage(accessToken, target.id, "语音格式转换失败,请稍后重试", replyToId ?? undefined);
503
- }
504
- } catch {}
505
- continue;
506
- }
507
- console.log(`[qqbot] sendText: Voice converted to SILK (${fileSize} bytes)`);
508
-
509
- if (target.type === "c2c") {
510
- const result = await sendC2CVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
511
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
512
- } else if (target.type === "group") {
513
- const result = await sendGroupVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
514
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
515
- } else {
516
- const result = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
517
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
518
- }
519
- console.log(`[qqbot] sendText: Sent voice via <qqvoice> tag: ${voicePath.slice(0, 60)}...`);
915
+ lastResult = await sendVoice(mediaTarget, item.content, undefined, account.config?.audioFormatPolicy?.transcodeEnabled !== false);
520
916
  } else if (item.type === "video") {
521
- // 发送视频(支持公网 URL 和本地文件)
522
- const videoPath = item.content;
523
- const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
524
-
525
- if (isHttpUrl) {
526
- // 公网 URL
527
- if (target.type === "c2c") {
528
- const result = await sendC2CVideoMessage(accessToken, target.id, videoPath, undefined, replyToId ?? undefined);
529
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
530
- } else if (target.type === "group") {
531
- const result = await sendGroupVideoMessage(accessToken, target.id, videoPath, undefined, replyToId ?? undefined);
532
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
533
- } else {
534
- const result = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
535
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
536
- }
537
- } else {
538
- // 本地文件:读取为 Base64
539
- if (!(await fileExistsAsync(videoPath))) {
540
- console.error(`[qqbot] sendText: Video file not found: ${videoPath}`);
541
- continue;
542
- }
543
- const videoSizeCheck = checkFileSize(videoPath);
544
- if (!videoSizeCheck.ok) {
545
- console.error(`[qqbot] sendText: ${videoSizeCheck.error}`);
546
- continue;
547
- }
548
- // 大文件进度提示
549
- if (isLargeFile(videoSizeCheck.size)) {
550
- try {
551
- const hint = `⏳ 正在上传视频 (${formatFileSize(videoSizeCheck.size)})...`;
552
- if (target.type === "c2c") {
553
- await sendC2CMessage(accessToken, target.id, hint, replyToId ?? undefined);
554
- } else if (target.type === "group") {
555
- await sendGroupMessage(accessToken, target.id, hint, replyToId ?? undefined);
556
- }
557
- } catch {}
558
- }
559
- const fileBuffer = await readFileAsync(videoPath);
560
- const videoBase64 = fileBuffer.toString("base64");
561
- console.log(`[qqbot] sendText: Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
562
-
563
- if (target.type === "c2c") {
564
- const result = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined, undefined, videoPath);
565
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
566
- } else if (target.type === "group") {
567
- const result = await sendGroupVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
568
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
569
- } else {
570
- const result = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
571
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
572
- }
573
- }
574
- console.log(`[qqbot] sendText: Sent video via <qqvideo> tag: ${videoPath.slice(0, 60)}...`);
917
+ lastResult = await sendVideoMsg(mediaTarget, item.content);
575
918
  } else if (item.type === "file") {
576
- // 发送文件
577
- const filePath = item.content;
578
- const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
579
- const fileName = sanitizeFileName(path.basename(filePath));
580
-
581
- if (isHttpUrl) {
582
- // 公网 URL:直接通过 url 参数上传
583
- if (target.type === "c2c") {
584
- const result = await sendC2CFileMessage(accessToken, target.id, undefined, filePath, replyToId ?? undefined, fileName);
585
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
586
- } else if (target.type === "group") {
587
- const result = await sendGroupFileMessage(accessToken, target.id, undefined, filePath, replyToId ?? undefined, fileName);
588
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
589
- } else {
590
- const result = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
591
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
592
- }
593
- } else {
594
- // 本地文件:读取转 Base64 上传
595
- if (!(await fileExistsAsync(filePath))) {
596
- console.error(`[qqbot] sendText: File not found: ${filePath}`);
597
- continue;
598
- }
599
- const fileSizeCheck = checkFileSize(filePath);
600
- if (!fileSizeCheck.ok) {
601
- console.error(`[qqbot] sendText: ${fileSizeCheck.error}`);
602
- continue;
603
- }
604
- // 大文件进度提示
605
- if (isLargeFile(fileSizeCheck.size)) {
606
- try {
607
- const hint = `⏳ 正在上传文件 ${fileName} (${formatFileSize(fileSizeCheck.size)})...`;
608
- if (target.type === "c2c") {
609
- await sendC2CMessage(accessToken, target.id, hint, replyToId ?? undefined);
610
- } else if (target.type === "group") {
611
- await sendGroupMessage(accessToken, target.id, hint, replyToId ?? undefined);
612
- }
613
- } catch {}
614
- }
615
- const fileBuffer = await readFileAsync(filePath);
616
- const fileBase64 = fileBuffer.toString("base64");
617
- console.log(`[qqbot] sendText: Read local file (${formatFileSize(fileBuffer.length)}): ${filePath}`);
618
-
619
- if (target.type === "c2c") {
620
- const result = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName, filePath);
621
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
622
- } else if (target.type === "group") {
623
- const result = await sendGroupFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
624
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
625
- } else {
626
- const result = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
627
- lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
628
- }
629
- }
630
- console.log(`[qqbot] sendText: Sent file via <qqfile> tag: ${filePath.slice(0, 60)}...`);
919
+ lastResult = await sendDocument(mediaTarget, item.content);
920
+ } else if (item.type === "media") {
921
+ // qqmedia: 自动根据扩展名路由
922
+ lastResult = await sendMedia({
923
+ to, text: "", mediaUrl: item.content,
924
+ accountId: account.accountId, replyToId, account,
925
+ });
631
926
  }
632
927
  } catch (err) {
633
928
  const errMsg = err instanceof Error ? err.message : String(err);
634
929
  console.error(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
635
- // 继续发送队列中的其他内容
636
930
  }
637
931
  }
638
932
 
@@ -804,428 +1098,94 @@ export async function sendProactiveMessage(
804
1098
  * ```
805
1099
  */
806
1100
  export async function sendMedia(ctx: MediaOutboundContext): Promise<OutboundResult> {
807
- const { to, text, replyToId, account } = ctx;
808
- // 展开波浪线路径:~/Desktop/file.png → /Users/xxx/Desktop/file.png
1101
+ const { to, text, replyToId, account, mimeType } = ctx;
809
1102
  const mediaUrl = normalizePath(ctx.mediaUrl);
810
1103
 
811
1104
  if (!account.appId || !account.clientSecret) {
812
1105
  return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
813
1106
  }
814
-
815
1107
  if (!mediaUrl) {
816
1108
  return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
817
1109
  }
818
1110
 
819
- // 判断是否为语音文件(本地文件路径 + 音频扩展名)
820
- const isLocalPath = isLocalFilePath(mediaUrl);
821
- const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
1111
+ const target = buildMediaTarget({ to, account, replyToId }, "[qqbot:sendMedia]");
822
1112
 
823
- if (isLocalPath && isAudioFile(mediaUrl)) {
824
- return sendVoiceFile(ctx);
825
- }
826
-
827
- // 判断是否为视频(公网 URL 或本地视频文件)
828
- if (isVideoFile(mediaUrl)) {
829
- if (isHttpUrl) {
830
- return sendVideoUrl(ctx);
1113
+ // 按类型分发(MIME 优先,扩展名回退)
1114
+ // 各 send* 函数内部已自带 URL 直传/下载策略(受 urlDirectUpload 开关控制)
1115
+ if (isAudioFile(mediaUrl, mimeType)) {
1116
+ const formats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
1117
+ const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
1118
+ const result = await sendVoice(target, mediaUrl, formats, transcodeEnabled);
1119
+ if (!result.error) {
1120
+ if (text?.trim()) await sendTextAfterMedia(target, text);
1121
+ return result;
831
1122
  }
832
- if (isLocalPath) {
833
- return sendVideoFile(ctx);
1123
+ // 语音发送失败 fallback 到文件发送(保留错误链)
1124
+ const voiceError = result.error;
1125
+ console.warn(`[qqbot] sendMedia: sendVoice failed (${voiceError}), falling back to sendDocument`);
1126
+ const fallback = await sendDocument(target, mediaUrl);
1127
+ if (!fallback.error) {
1128
+ if (text?.trim()) await sendTextAfterMedia(target, text);
1129
+ return fallback;
834
1130
  }
1131
+ return { channel: "qqbot", error: `voice: ${voiceError} | fallback file: ${fallback.error}` };
835
1132
  }
836
1133
 
837
- // 判断是否为文档/文件(非图片、非音频、非视频的本地文件)
838
- if (isLocalPath && !isImageFile(mediaUrl) && !isAudioFile(mediaUrl)) {
839
- return sendDocumentFile(ctx);
1134
+ if (isVideoFile(mediaUrl, mimeType)) {
1135
+ const result = await sendVideoMsg(target, mediaUrl);
1136
+ if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
1137
+ return result;
840
1138
  }
841
1139
 
842
- // === 以下为图片发送逻辑(原有逻辑) ===
843
-
844
- const isDataUrl = mediaUrl.startsWith("data:");
845
-
846
- let processedMediaUrl = mediaUrl;
847
-
848
- if (isLocalPath) {
849
- console.log(`[qqbot] sendMedia: local file path detected: ${mediaUrl}`);
850
-
851
- try {
852
- if (!(await fileExistsAsync(mediaUrl))) {
853
- return { channel: "qqbot", error: `本地文件不存在: ${mediaUrl}` };
854
- }
855
-
856
- // 文件大小校验
857
- const sizeCheck = checkFileSize(mediaUrl);
858
- if (!sizeCheck.ok) {
859
- return { channel: "qqbot", error: sizeCheck.error! };
860
- }
861
-
862
- const fileBuffer = await readFileAsync(mediaUrl);
863
- const base64Data = fileBuffer.toString("base64");
864
-
865
- const ext = path.extname(mediaUrl).toLowerCase();
866
- const mimeTypes: Record<string, string> = {
867
- ".jpg": "image/jpeg",
868
- ".jpeg": "image/jpeg",
869
- ".png": "image/png",
870
- ".gif": "image/gif",
871
- ".webp": "image/webp",
872
- ".bmp": "image/bmp",
873
- };
874
-
875
- const mimeType = mimeTypes[ext];
876
- if (!mimeType) {
877
- return {
878
- channel: "qqbot",
879
- error: `不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}`
880
- };
881
- }
882
-
883
- processedMediaUrl = `data:${mimeType};base64,${base64Data}`;
884
- console.log(`[qqbot] sendMedia: local file converted to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType})`);
885
-
886
- } catch (readErr) {
887
- const errMsg = readErr instanceof Error ? readErr.message : String(readErr);
888
- console.error(`[qqbot] sendMedia: failed to read local file: ${errMsg}`);
889
- return { channel: "qqbot", error: `读取本地文件失败: ${errMsg}` };
890
- }
891
- } else if (!isHttpUrl && !isDataUrl) {
892
- console.log(`[qqbot] sendMedia: unsupported media format: ${mediaUrl.slice(0, 50)}`);
893
- return {
894
- channel: "qqbot",
895
- error: `不支持的媒体格式: ${mediaUrl.slice(0, 50)}...。支持: 公网 URL、Base64 Data URL 或本地文件路径(图片/音频)。`
896
- };
897
- } else if (isDataUrl) {
898
- console.log(`[qqbot] sendMedia: sending Base64 image (length: ${mediaUrl.length})`);
899
- } else {
900
- console.log(`[qqbot] sendMedia: sending image URL: ${mediaUrl.slice(0, 80)}...`);
1140
+ // 非图片、非音频、非视频 文件发送
1141
+ if (!isImageFile(mediaUrl, mimeType) && !isAudioFile(mediaUrl, mimeType) && !isVideoFile(mediaUrl, mimeType)) {
1142
+ const result = await sendDocument(target, mediaUrl);
1143
+ if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
1144
+ return result;
901
1145
  }
902
1146
 
903
- try {
904
- const accessToken = await getAccessToken(account.appId, account.clientSecret);
905
- const target = parseTarget(to);
906
-
907
- let imageResult: { id: string; timestamp: number | string };
908
- if (target.type === "c2c") {
909
- imageResult = await sendC2CImageMessage(
910
- accessToken, target.id, processedMediaUrl, replyToId ?? undefined, undefined, isLocalPath ? mediaUrl : undefined
911
- );
912
- } else if (target.type === "group") {
913
- imageResult = await sendGroupImageMessage(
914
- accessToken, target.id, processedMediaUrl, replyToId ?? undefined, undefined
915
- );
916
- } else {
917
- const displayUrl = isLocalPath ? "[本地文件]" : mediaUrl;
918
- const textWithUrl = text ? `${text}\n${displayUrl}` : displayUrl;
919
- const result = await sendChannelMessage(accessToken, target.id, textWithUrl, replyToId ?? undefined);
920
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
921
- }
922
-
923
- if (text?.trim()) {
924
- try {
925
- if (target.type === "c2c") {
926
- await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
927
- } else if (target.type === "group") {
928
- await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
929
- }
930
- } catch (textErr) {
931
- console.error(`[qqbot] Failed to send text after image: ${textErr}`);
932
- }
933
- }
934
-
935
- return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp, refIdx: (imageResult as any).ext_info?.ref_idx };
936
- } catch (err) {
937
- const message = err instanceof Error ? err.message : String(err);
938
- return { channel: "qqbot", error: message };
939
- }
1147
+ // 默认:图片(sendPhoto 内置 URL fallback)
1148
+ const result = await sendPhoto(target, mediaUrl);
1149
+ if (!result.error && text?.trim()) await sendTextAfterMedia(target, text);
1150
+ return result;
940
1151
  }
941
1152
 
942
- /**
943
- * 发送语音文件消息
944
- * 流程类似图片发送:读取本地音频文件 → 转为 SILK Base64 → 上传 → 发送
945
- */
946
- async function sendVoiceFile(ctx: MediaOutboundContext): Promise<OutboundResult> {
947
- const { to, text, replyToId, account, mediaUrl } = ctx;
948
-
949
- console.log(`[qqbot] sendVoiceFile: ${mediaUrl}`);
950
-
951
- // 等待文件就绪(TTS 工具异步生成,文件可能还没写完)
952
- const fileSize = await waitForFile(mediaUrl);
953
- if (fileSize === 0) {
954
- return { channel: "qqbot", error: `语音生成失败,请稍后重试` };
955
- }
956
-
1153
+ /** 发送媒体后附带文本说明 */
1154
+ async function sendTextAfterMedia(ctx: MediaTargetContext, text: string): Promise<void> {
957
1155
  try {
958
- // 尝试转换为 SILK 格式(QQ 语音要求 SILK 格式),支持配置直传格式跳过转换
959
- const directFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
960
- const silkBase64 = await audioFileToSilkBase64(mediaUrl, directFormats);
961
- if (!silkBase64) {
962
- // 如果无法转换为 SILK,直接读取文件作为 Base64 上传(让 API 尝试处理)
963
- const buf = await readFileAsync(mediaUrl);
964
- const fallbackBase64 = buf.toString("base64");
965
- console.log(`[qqbot] sendVoiceFile: not SILK format, uploading raw file (${formatFileSize(buf.length)})`);
966
-
967
- const accessToken = await getAccessToken(account.appId!, account.clientSecret!);
968
- const target = parseTarget(to);
969
-
970
- let result: { id: string; timestamp: number | string };
971
- if (target.type === "c2c") {
972
- result = await sendC2CVoiceMessage(accessToken, target.id, fallbackBase64, replyToId ?? undefined);
973
- } else if (target.type === "group") {
974
- result = await sendGroupVoiceMessage(accessToken, target.id, fallbackBase64, replyToId ?? undefined);
975
- } else {
976
- const r = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
977
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
978
- }
979
-
980
- return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
981
- }
982
-
983
- console.log(`[qqbot] sendVoiceFile: SILK format ready, uploading...`);
984
-
985
- const accessToken = await getAccessToken(account.appId!, account.clientSecret!);
986
- const target = parseTarget(to);
987
-
988
- let voiceResult: { id: string; timestamp: number | string };
989
- if (target.type === "c2c") {
990
- voiceResult = await sendC2CVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
991
- } else if (target.type === "group") {
992
- voiceResult = await sendGroupVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
993
- } else {
994
- const r = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
995
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
1156
+ const token = await getToken(ctx.account);
1157
+ if (ctx.targetType === "c2c") {
1158
+ await sendC2CMessage(token, ctx.targetId, text, ctx.replyToId);
1159
+ } else if (ctx.targetType === "group") {
1160
+ await sendGroupMessage(token, ctx.targetId, text, ctx.replyToId);
996
1161
  }
997
-
998
- // 如果有文本说明,再发送一条文本消息
999
- if (text?.trim()) {
1000
- try {
1001
- if (target.type === "c2c") {
1002
- await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
1003
- } else if (target.type === "group") {
1004
- await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
1005
- }
1006
- } catch (textErr) {
1007
- console.error(`[qqbot] Failed to send text after voice: ${textErr}`);
1008
- }
1009
- }
1010
-
1011
- console.log(`[qqbot] sendVoiceFile: voice message sent`);
1012
- return { channel: "qqbot", messageId: voiceResult.id, timestamp: voiceResult.timestamp, refIdx: (voiceResult as any).ext_info?.ref_idx };
1013
1162
  } catch (err) {
1014
- const message = err instanceof Error ? err.message : String(err);
1015
- console.error(`[qqbot] sendVoiceFile: failed: ${message}`);
1016
- return { channel: "qqbot", error: message };
1163
+ console.error(`[qqbot] sendTextAfterMedia failed: ${err}`);
1017
1164
  }
1018
1165
  }
1019
1166
 
1020
- /** 判断文件是否为图片格式 */
1021
- function isImageFile(filePath: string): boolean {
1022
- const ext = path.extname(filePath).toLowerCase();
1023
- return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext);
1024
- }
1025
-
1026
- /** 判断文件/URL 是否为视频格式 */
1027
- function isVideoFile(filePath: string): boolean {
1028
- // 去掉 URL query 参数后判断扩展名
1029
- const cleanPath = filePath.split("?")[0]!;
1030
- const ext = path.extname(cleanPath).toLowerCase();
1031
- return [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"].includes(ext);
1167
+ /** 从路径/URL 中提取扩展名(去除查询参数和 hash) */
1168
+ function getCleanExt(filePath: string): string {
1169
+ const cleanPath = filePath.split("?")[0]!.split("#")[0]!;
1170
+ return path.extname(cleanPath).toLowerCase();
1032
1171
  }
1033
1172
 
1034
- /**
1035
- * 发送视频消息(公网 URL)
1036
- */
1037
- async function sendVideoUrl(ctx: MediaOutboundContext): Promise<OutboundResult> {
1038
- const { to, text, replyToId, account, mediaUrl } = ctx;
1039
-
1040
- console.log(`[qqbot] sendVideoUrl: ${mediaUrl}`);
1041
-
1042
- if (!account.appId || !account.clientSecret) {
1043
- return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
1044
- }
1045
-
1046
- try {
1047
- const accessToken = await getAccessToken(account.appId, account.clientSecret);
1048
- const target = parseTarget(to);
1049
-
1050
- let videoResult: { id: string; timestamp: number | string };
1051
- if (target.type === "c2c") {
1052
- videoResult = await sendC2CVideoMessage(accessToken, target.id, mediaUrl, undefined, replyToId ?? undefined);
1053
- } else if (target.type === "group") {
1054
- videoResult = await sendGroupVideoMessage(accessToken, target.id, mediaUrl, undefined, replyToId ?? undefined);
1055
- } else {
1056
- const r = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
1057
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
1058
- }
1059
-
1060
- // 如果有文本说明,再发送一条文本消息
1061
- if (text?.trim()) {
1062
- try {
1063
- if (target.type === "c2c") {
1064
- await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
1065
- } else if (target.type === "group") {
1066
- await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
1067
- }
1068
- } catch (textErr) {
1069
- console.error(`[qqbot] Failed to send text after video: ${textErr}`);
1070
- }
1071
- }
1072
-
1073
- console.log(`[qqbot] sendVideoUrl: video message sent`);
1074
- return { channel: "qqbot", messageId: videoResult.id, timestamp: videoResult.timestamp, refIdx: (videoResult as any).ext_info?.ref_idx };
1075
- } catch (err) {
1076
- const message = err instanceof Error ? err.message : String(err);
1077
- console.error(`[qqbot] sendVideoUrl: failed: ${message}`);
1078
- return { channel: "qqbot", error: message };
1079
- }
1080
- }
1081
-
1082
- /**
1083
- * 发送本地视频文件
1084
- * 流程:读取本地文件 → Base64 → 上传(file_type=2) → 发送
1085
- */
1086
- async function sendVideoFile(ctx: MediaOutboundContext): Promise<OutboundResult> {
1087
- const { to, text, replyToId, account, mediaUrl } = ctx;
1088
-
1089
- console.log(`[qqbot] sendVideoFile: ${mediaUrl}`);
1090
-
1091
- if (!account.appId || !account.clientSecret) {
1092
- return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
1093
- }
1094
-
1095
- try {
1096
- if (!(await fileExistsAsync(mediaUrl))) {
1097
- return { channel: "qqbot", error: `视频文件不存在: ${mediaUrl}` };
1098
- }
1099
-
1100
- // 文件大小校验
1101
- const sizeCheck = checkFileSize(mediaUrl);
1102
- if (!sizeCheck.ok) {
1103
- return { channel: "qqbot", error: sizeCheck.error! };
1104
- }
1105
-
1106
- const fileBuffer = await readFileAsync(mediaUrl);
1107
- const videoBase64 = fileBuffer.toString("base64");
1108
- console.log(`[qqbot] sendVideoFile: Read local video (${formatFileSize(fileBuffer.length)})`);
1109
-
1110
- const accessToken = await getAccessToken(account.appId, account.clientSecret);
1111
- const target = parseTarget(to);
1112
-
1113
- let videoResult: { id: string; timestamp: number | string };
1114
- if (target.type === "c2c") {
1115
- videoResult = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined, undefined, mediaUrl);
1116
- } else if (target.type === "group") {
1117
- videoResult = await sendGroupVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
1118
- } else {
1119
- const r = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
1120
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
1121
- }
1122
-
1123
- // 如果有文本说明,再发送一条文本消息
1124
- if (text?.trim()) {
1125
- try {
1126
- if (target.type === "c2c") {
1127
- await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
1128
- } else if (target.type === "group") {
1129
- await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
1130
- }
1131
- } catch (textErr) {
1132
- console.error(`[qqbot] Failed to send text after video: ${textErr}`);
1133
- }
1134
- }
1135
-
1136
- console.log(`[qqbot] sendVideoFile: video message sent`);
1137
- return { channel: "qqbot", messageId: videoResult.id, timestamp: videoResult.timestamp, refIdx: (videoResult as any).ext_info?.ref_idx };
1138
- } catch (err) {
1139
- const message = err instanceof Error ? err.message : String(err);
1140
- console.error(`[qqbot] sendVideoFile: failed: ${message}`);
1141
- return { channel: "qqbot", error: message };
1173
+ /** 判断文件是否为图片格式(MIME 优先,扩展名回退) */
1174
+ function isImageFile(filePath: string, mimeType?: string): boolean {
1175
+ if (mimeType) {
1176
+ if (mimeType.startsWith("image/")) return true;
1142
1177
  }
1178
+ const ext = getCleanExt(filePath);
1179
+ return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext);
1143
1180
  }
1144
1181
 
1145
- /**
1146
- * 发送文件消息
1147
- * 流程:读取本地文件 → Base64 → 上传(file_type=4) → 发送
1148
- * 支持本地文件路径和公网 URL
1149
- */
1150
- async function sendDocumentFile(ctx: MediaOutboundContext): Promise<OutboundResult> {
1151
- const { to, text, replyToId, account, mediaUrl } = ctx;
1152
-
1153
- console.log(`[qqbot] sendDocumentFile: ${mediaUrl}`);
1154
-
1155
- if (!account.appId || !account.clientSecret) {
1156
- return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
1157
- }
1158
-
1159
- const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
1160
- const fileName = sanitizeFileName(path.basename(mediaUrl));
1161
-
1162
- try {
1163
- const accessToken = await getAccessToken(account.appId, account.clientSecret);
1164
- const target = parseTarget(to);
1165
-
1166
- let fileResult: { id: string; timestamp: number | string };
1167
-
1168
- if (isHttpUrl) {
1169
- // 公网 URL:通过 url 参数上传
1170
- console.log(`[qqbot] sendDocumentFile: uploading via URL: ${mediaUrl}`);
1171
- if (target.type === "c2c") {
1172
- fileResult = await sendC2CFileMessage(accessToken, target.id, undefined, mediaUrl, replyToId ?? undefined, fileName);
1173
- } else if (target.type === "group") {
1174
- fileResult = await sendGroupFileMessage(accessToken, target.id, undefined, mediaUrl, replyToId ?? undefined, fileName);
1175
- } else {
1176
- const r = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
1177
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
1178
- }
1179
- } else {
1180
- // 本地文件:读取转 Base64 上传
1181
- if (!(await fileExistsAsync(mediaUrl))) {
1182
- return { channel: "qqbot", error: `本地文件不存在: ${mediaUrl}` };
1183
- }
1184
-
1185
- // 文件大小校验
1186
- const docSizeCheck = checkFileSize(mediaUrl);
1187
- if (!docSizeCheck.ok) {
1188
- return { channel: "qqbot", error: docSizeCheck.error! };
1189
- }
1190
-
1191
- const fileBuffer = await readFileAsync(mediaUrl);
1192
- if (fileBuffer.length === 0) {
1193
- return { channel: "qqbot", error: `文件内容为空: ${mediaUrl}` };
1194
- }
1195
-
1196
- const fileBase64 = fileBuffer.toString("base64");
1197
- console.log(`[qqbot] sendDocumentFile: read local file (${formatFileSize(fileBuffer.length)}), uploading...`);
1198
-
1199
- if (target.type === "c2c") {
1200
- fileResult = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName, mediaUrl);
1201
- } else if (target.type === "group") {
1202
- fileResult = await sendGroupFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
1203
- } else {
1204
- const r = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
1205
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
1206
- }
1207
- }
1208
-
1209
- // 如果有附带文本说明,再发送一条文本消息
1210
- if (text?.trim()) {
1211
- try {
1212
- if (target.type === "c2c") {
1213
- await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
1214
- } else if (target.type === "group") {
1215
- await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
1216
- }
1217
- } catch (textErr) {
1218
- console.error(`[qqbot] Failed to send text after file: ${textErr}`);
1219
- }
1220
- }
1221
-
1222
- console.log(`[qqbot] sendDocumentFile: file message sent`);
1223
- return { channel: "qqbot", messageId: fileResult.id, timestamp: fileResult.timestamp, refIdx: (fileResult as any).ext_info?.ref_idx };
1224
- } catch (err) {
1225
- const message = err instanceof Error ? err.message : String(err);
1226
- console.error(`[qqbot] sendDocumentFile: failed: ${message}`);
1227
- return { channel: "qqbot", error: message };
1182
+ /** 判断文件/URL 是否为视频格式(MIME 优先,扩展名回退) */
1183
+ function isVideoFile(filePath: string, mimeType?: string): boolean {
1184
+ if (mimeType) {
1185
+ if (mimeType.startsWith("video/")) return true;
1228
1186
  }
1187
+ const ext = getCleanExt(filePath);
1188
+ return [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"].includes(ext);
1229
1189
  }
1230
1190
 
1231
1191
  /**