@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/gateway.ts CHANGED
@@ -2,18 +2,22 @@ import WebSocket from "ws";
2
2
  import path from "node:path";
3
3
  import * as fs from "node:fs";
4
4
  import type { ResolvedQQBotAccount, WSPayload, C2CMessageEvent, GuildMessageEvent, GroupMessageEvent } from "./types.js";
5
- import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent } from "./api.js";
5
+ import { getAccessToken, getGatewayUrl, sendC2CMessage, sendChannelMessage, sendGroupMessage, clearTokenCache, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage, initApiConfig, startBackgroundTokenRefresh, stopBackgroundTokenRefresh, sendC2CInputNotify, onMessageSent, PLUGIN_USER_AGENT, sendProactiveC2CMessage, sendProactiveGroupMessage } from "./api.js";
6
6
  import { loadSession, saveSession, clearSession, type SessionState } from "./session-store.js";
7
- import { recordKnownUser, flushKnownUsers } from "./known-users.js";
7
+ import { recordKnownUser, flushKnownUsers, listKnownUsers } from "./known-users.js";
8
8
  import { getQQBotRuntime } from "./runtime.js";
9
9
  import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex, type RefAttachmentSummary } from "./ref-index-store.js";
10
+ import { matchSlashCommand, getPluginVersion, type SlashCommandContext, type SlashCommandFileResult, type QueueSnapshot } from "./slash-commands.js";
11
+ import { triggerUpdateCheck } from "./update-checker.js";
10
12
  import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
11
13
  import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js";
12
14
  import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type CronReminderPayload, type MediaPayload } from "./utils/payload.js";
13
- import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig, textToSilk, audioFileToSilkBase64, waitForFile, isAudioFile } from "./utils/audio-convert.js";
15
+ import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig, textToSilk } from "./utils/audio-convert.js";
14
16
  import { normalizeMediaTags } from "./utils/media-tags.js";
15
17
  import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
16
- import { getQQBotDataDir, isLocalPath as isLocalFilePath, looksLikeLocalPath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
18
+ import { getQQBotDataDir, isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
19
+ import { MSG, formatMediaErrorMessage } from "./user-messages.js";
20
+ import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
17
21
 
18
22
  /**
19
23
  * 通用 OpenAI 兼容 STT(语音转文字)
@@ -108,27 +112,9 @@ const INTENTS = {
108
112
  GROUP_AND_C2C: 1 << 25, // 群聊和 C2C 私聊(需申请)
109
113
  };
110
114
 
111
- // 权限级别:从高到低依次尝试
112
- const INTENT_LEVELS = [
113
- // Level 0: 完整权限(群聊 + 私信 + 频道)
114
- {
115
- name: "full",
116
- intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C,
117
- description: "群聊+私信+频道",
118
- },
119
- // Level 1: 群聊 + 频道(无私信)
120
- {
121
- name: "group+channel",
122
- intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GROUP_AND_C2C,
123
- description: "群聊+频道",
124
- },
125
- // Level 2: 仅频道(基础权限)
126
- {
127
- name: "channel-only",
128
- intents: INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.GUILD_MEMBERS,
129
- description: "仅频道消息",
130
- },
131
- ];
115
+ // 固定使用完整权限(群聊 + 私信 + 频道),不做降级
116
+ const FULL_INTENTS = INTENTS.PUBLIC_GUILD_MESSAGES | INTENTS.DIRECT_MESSAGE | INTENTS.GROUP_AND_C2C;
117
+ const FULL_INTENTS_DESC = "群聊+私信+频道";
132
118
 
133
119
  // 重连配置
134
120
  const RECONNECT_DELAYS = [1000, 2000, 5000, 10000, 30000, 60000]; // 递增延迟
@@ -235,27 +221,7 @@ function parseFaceTags(text: string): string {
235
221
  });
236
222
  }
237
223
 
238
- // ============ 媒体发送友好错误提示 ============
239
-
240
- /**
241
- * 将媒体上传/发送错误转为对用户友好的提示文案
242
- */
243
- function formatMediaErrorMessage(mediaType: string, err: unknown): string {
244
- const msg = err instanceof Error ? err.message : String(err);
245
- if (msg.includes("上传超时") || msg.includes("timeout") || msg.includes("Timeout")) {
246
- return `抱歉,${mediaType}资源加载超时,可能是网络原因或文件太大,请稍后再试~`;
247
- }
248
- if (msg.includes("文件不存在") || msg.includes("not found") || msg.includes("Not Found")) {
249
- return `抱歉,${mediaType}文件不存在或已失效,无法发送~`;
250
- }
251
- if (msg.includes("文件大小") || msg.includes("too large") || msg.includes("exceed")) {
252
- return `抱歉,${mediaType}文件太大了,超出了发送限制~`;
253
- }
254
- if (msg.includes("Network error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
255
- return `抱歉,网络连接异常,${mediaType}发送失败,请稍后再试~`;
256
- }
257
- return `抱歉,${mediaType}发送失败了,请稍后再试~`;
258
- }
224
+ // formatMediaErrorMessage 已移至 user-messages.ts 集中管理
259
225
 
260
226
  // ============ 内部标记过滤 ============
261
227
 
@@ -378,6 +344,49 @@ async function ensureImageServer(log?: GatewayContext["log"], publicBaseUrl?: st
378
344
  }
379
345
  }
380
346
 
347
+ // ============ 启动问候语(首次安装/版本更新 vs 普通重启) ============
348
+
349
+ // 模块级变量:进程生命周期内只有首次为 true
350
+ // 区分 gateway restart(进程重启)和 health-monitor 断线重连
351
+ let isFirstReadyGlobal = true;
352
+
353
+ const STARTUP_MARKER_FILE = path.join(getQQBotDataDir("data"), "startup-marker.json");
354
+
355
+ /**
356
+ * 判断是否为首次安装或版本更新,返回对应的问候语。
357
+ * - 首次安装 / 版本变更 → "Haha,我的'灵魂'已上线,随时等你吩咐。"
358
+ * - 普通重启 → "我重新登上了,有事随时找我。"
359
+ */
360
+ function getStartupGreeting(): string {
361
+ const currentVersion = getPluginVersion();
362
+ let isFirstOrUpdated = true;
363
+
364
+ try {
365
+ if (fs.existsSync(STARTUP_MARKER_FILE)) {
366
+ const data = JSON.parse(fs.readFileSync(STARTUP_MARKER_FILE, "utf8"));
367
+ if (data.version === currentVersion) {
368
+ isFirstOrUpdated = false;
369
+ }
370
+ }
371
+ } catch {
372
+ // 文件损坏或不存在,视为首次
373
+ }
374
+
375
+ // 更新 marker 文件
376
+ try {
377
+ fs.writeFileSync(STARTUP_MARKER_FILE, JSON.stringify({
378
+ version: currentVersion,
379
+ startedAt: new Date().toISOString(),
380
+ }) + "\n");
381
+ } catch {
382
+ // ignore
383
+ }
384
+
385
+ return isFirstOrUpdated
386
+ ? `Haha,我的'灵魂'已上线,随时等你吩咐。`
387
+ : `我重新登上了,有事随时找我。`;
388
+ }
389
+
381
390
  /**
382
391
  * 启动 Gateway WebSocket 连接(带自动重连)
383
392
  * 支持流式消息发送
@@ -397,6 +406,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
397
406
  }
398
407
  }
399
408
 
409
+ // 后台版本检查(detached 子进程,零阻塞)
410
+ triggerUpdateCheck(log);
411
+
400
412
  // 初始化 API 配置(markdown 支持)
401
413
  initApiConfig({
402
414
  markdownSupport: account.markdownSupport,
@@ -471,8 +483,46 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
471
483
  let isConnecting = false; // 防止并发连接
472
484
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null; // 重连定时器
473
485
  let shouldRefreshToken = false; // 下次连接是否需要刷新 token
474
- let intentLevelIndex = 0; // 当前尝试的权限级别索引
475
- let lastSuccessfulIntentLevel = -1; // 上次成功的权限级别
486
+ // 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
487
+ // health-monitor 重连不会重新初始化为 true
488
+
489
+ /** 异步发送启动问候语(READY 或 RESUMED 时调用) */
490
+ const sendStartupGreetings = (trigger: "READY" | "RESUMED") => {
491
+ (async () => {
492
+ try {
493
+ const greeting = getStartupGreeting();
494
+ log?.info(`[qqbot:${account.accountId}] Sending startup greeting (trigger=${trigger}): "${greeting}"`);
495
+ const token = await getAccessToken(account.appId, account.clientSecret);
496
+ const users = listKnownUsers({ accountId: account.accountId, type: "c2c" });
497
+ for (const user of users) {
498
+ try {
499
+ await sendProactiveC2CMessage(token, user.openid, greeting);
500
+ log?.info(`[qqbot:${account.accountId}] Sent startup greeting to c2c:${user.openid}`);
501
+ } catch (err) {
502
+ log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to c2c:${user.openid}: ${err}`);
503
+ }
504
+ await new Promise(r => setTimeout(r, 500));
505
+ }
506
+ const groups = listKnownUsers({ accountId: account.accountId, type: "group" });
507
+ const sentGroups = new Set<string>();
508
+ for (const user of groups) {
509
+ const gid = user.groupOpenid;
510
+ if (!gid || sentGroups.has(gid)) continue;
511
+ sentGroups.add(gid);
512
+ try {
513
+ await sendProactiveGroupMessage(token, gid, greeting);
514
+ log?.info(`[qqbot:${account.accountId}] Sent startup greeting to group:${gid}`);
515
+ } catch (err) {
516
+ log?.debug?.(`[qqbot:${account.accountId}] Failed to send startup greeting to group:${gid}: ${err}`);
517
+ }
518
+ await new Promise(r => setTimeout(r, 500));
519
+ }
520
+ log?.info(`[qqbot:${account.accountId}] Startup greetings sent (${users.length} c2c, ${sentGroups.size} groups)`);
521
+ } catch (err) {
522
+ log?.error(`[qqbot:${account.accountId}] Failed to send startup greetings: ${err}`);
523
+ }
524
+ })();
525
+ };
476
526
 
477
527
  // ============ P1-2: 尝试从持久化存储恢复 Session ============
478
528
  // 传入当前 appId,如果 appId 已变更(换了机器人),旧 session 自动失效
@@ -480,9 +530,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
480
530
  if (savedSession) {
481
531
  sessionId = savedSession.sessionId;
482
532
  lastSeq = savedSession.lastSeq;
483
- intentLevelIndex = savedSession.intentLevelIndex;
484
- lastSuccessfulIntentLevel = savedSession.intentLevelIndex;
485
- log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}, intentLevel=${intentLevelIndex}`);
533
+ log?.info(`[qqbot:${account.accountId}] Restored session from storage: sessionId=${sessionId}, lastSeq=${lastSeq}`);
486
534
  }
487
535
 
488
536
  // ============ 按用户并发的消息队列(同用户串行,跨用户并行) ============
@@ -575,6 +623,101 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
575
623
  log?.info(`[qqbot:${account.accountId}] Message processor started (per-user concurrency, max ${MAX_CONCURRENT_USERS} users)`);
576
624
  };
577
625
 
626
+ // 获取队列状态快照(供斜杠指令使用)
627
+ const getQueueSnapshot = (senderPeerId: string): QueueSnapshot => {
628
+ let totalPending = 0;
629
+ for (const [, q] of userQueues) {
630
+ totalPending += q.length;
631
+ }
632
+ const senderQueue = userQueues.get(senderPeerId);
633
+ return {
634
+ totalPending,
635
+ activeUsers: activeUsers.size,
636
+ maxConcurrentUsers: MAX_CONCURRENT_USERS,
637
+ senderPending: senderQueue ? senderQueue.length : 0,
638
+ };
639
+ };
640
+
641
+ // 斜杠指令拦截:在入队前匹配插件级指令,命中则直接回复,不入队
642
+ const trySlashCommandOrEnqueue = async (msg: QueuedMessage): Promise<void> => {
643
+ const content = (msg.content ?? "").trim();
644
+ if (!content.startsWith("/")) {
645
+ enqueueMessage(msg);
646
+ return;
647
+ }
648
+
649
+ const receivedAt = Date.now();
650
+ const peerId = getMessagePeerId(msg);
651
+
652
+ const cmdCtx: SlashCommandContext = {
653
+ type: msg.type,
654
+ senderId: msg.senderId,
655
+ senderName: msg.senderName,
656
+ messageId: msg.messageId,
657
+ eventTimestamp: msg.timestamp,
658
+ receivedAt,
659
+ rawContent: content,
660
+ args: "",
661
+ channelId: msg.channelId,
662
+ groupOpenid: msg.groupOpenid,
663
+ accountId: account.accountId,
664
+ accountConfig: account.config,
665
+ queueSnapshot: getQueueSnapshot(peerId),
666
+ };
667
+
668
+ try {
669
+ const reply = await matchSlashCommand(cmdCtx);
670
+ if (reply === null) {
671
+ // 不是插件级指令,正常入队交给框架
672
+ enqueueMessage(msg);
673
+ return;
674
+ }
675
+
676
+ // 命中插件级指令,直接回复
677
+ log?.info(`[qqbot:${account.accountId}] Slash command matched: ${content}, replying directly`);
678
+ const token = await getAccessToken(account.appId, account.clientSecret);
679
+
680
+ // 解析回复:纯文本 or 带文件的结果
681
+ const isFileResult = typeof reply === "object" && reply !== null && "filePath" in reply;
682
+ const replyText = isFileResult ? (reply as SlashCommandFileResult).text : reply as string;
683
+ const replyFile = isFileResult ? (reply as SlashCommandFileResult).filePath : null;
684
+
685
+ // 先发送文本回复
686
+ if (msg.type === "c2c") {
687
+ await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
688
+ } else if (msg.type === "group" && msg.groupOpenid) {
689
+ await sendGroupMessage(token, msg.groupOpenid, replyText, msg.messageId);
690
+ } else if (msg.channelId) {
691
+ await sendChannelMessage(token, msg.channelId, replyText, msg.messageId);
692
+ } else if (msg.type === "dm") {
693
+ await sendC2CMessage(token, msg.senderId, replyText, msg.messageId);
694
+ }
695
+
696
+ // 如果有文件需要发送
697
+ if (replyFile) {
698
+ try {
699
+ const targetType = msg.type === "group" ? "group" : msg.type === "c2c" || msg.type === "dm" ? "c2c" : "channel";
700
+ const targetId = msg.type === "group" ? (msg.groupOpenid || msg.senderId) : msg.type === "c2c" || msg.type === "dm" ? msg.senderId : (msg.channelId || msg.senderId);
701
+ const mediaCtx: MediaTargetContext = {
702
+ targetType,
703
+ targetId,
704
+ account,
705
+ replyToId: msg.messageId,
706
+ logPrefix: `[qqbot:${account.accountId}]`,
707
+ };
708
+ await sendDocument(mediaCtx, replyFile);
709
+ log?.info(`[qqbot:${account.accountId}] Slash command file sent: ${replyFile}`);
710
+ } catch (fileErr) {
711
+ log?.error(`[qqbot:${account.accountId}] Failed to send slash command file: ${fileErr}`);
712
+ }
713
+ }
714
+ } catch (err) {
715
+ log?.error(`[qqbot:${account.accountId}] Slash command error: ${err}`);
716
+ // 出错时回退到正常入队
717
+ enqueueMessage(msg);
718
+ }
719
+ };
720
+
578
721
  abortSignal.addEventListener("abort", () => {
579
722
  isAborted = true;
580
723
  if (reconnectTimer) {
@@ -654,7 +797,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
654
797
 
655
798
  log?.info(`[qqbot:${account.accountId}] Connecting to ${gatewayUrl}`);
656
799
 
657
- const ws = new WebSocket(gatewayUrl);
800
+ const ws = new WebSocket(gatewayUrl, { headers: { "User-Agent": PLUGIN_USER_AGENT } });
658
801
  currentWs = ws;
659
802
 
660
803
  const pluginRuntime = getQQBotRuntime();
@@ -900,13 +1043,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
900
1043
  const hasAsrReferFallback = voiceTranscriptSources.includes("asr");
901
1044
  if (voiceTranscripts.length > 0) {
902
1045
  voiceText = voiceTranscripts.length === 1
903
- ? `${voiceTranscriptSources[0] === "asr" ? "[语音消息(ASR兜底,可能不准确)]" : "[语音消息]"} ${voiceTranscripts[0]}`
904
- : voiceTranscripts.map((t, i) => {
905
- const prefix = voiceTranscriptSources[i] === "asr"
906
- ? `[语音${i + 1}(ASR兜底,可能不准确)]`
907
- : `[语音${i + 1}]`;
908
- return `${prefix} ${t}`;
909
- }).join("\n");
1046
+ ? `[语音消息] ${voiceTranscripts[0]}`
1047
+ : voiceTranscripts.map((t, i) => `[语音${i + 1}] ${t}`).join("\n");
910
1048
  }
911
1049
 
912
1050
  // 解析 QQ 表情标签,将 <faceType=...,ext="base64"> 替换为 【表情: 中文名】
@@ -1003,58 +1141,11 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1003
1141
  + (asrPreview ? `, asr_preview="${asrPreview}${uniqueVoiceAsrReferTexts[0].length > 50 ? "..." : ""}"` : "")
1004
1142
  );
1005
1143
  }
1006
- let receivedMediaSection = "";
1007
- if (imageUrls.length > 0 || uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
1008
- const mediaSections: string[] = [];
1009
- if (imageUrls.length > 0) {
1010
- const imageEntries = imageUrls.map((p, i) => ` - ${p} (${imageMediaTypes[i] || "unknown"})`);
1011
- mediaSections.push(`- 图片附件:\n${imageEntries.join("\n")}`);
1012
- }
1013
- if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
1014
- const voiceEntries = [
1015
- ...uniqueVoicePaths.map((p) => ` - ${p} (local audio)`),
1016
- ...uniqueVoiceUrls.map((u) => ` - ${u} (remote audio)`),
1017
- ];
1018
- mediaSections.push(`- 语音附件:\n${voiceEntries.join("\n")}`);
1019
- }
1020
- receivedMediaSection = `\n${mediaSections.join("\n")}`;
1021
- }
1022
-
1023
1144
  // AI 看到的投递地址必须带完整前缀(qqbot:c2c: / qqbot:group:)
1024
1145
  const qualifiedTarget = isGroupChat ? `qqbot:group:${event.groupOpenid}` : `qqbot:c2c:${event.senderId}`;
1025
1146
 
1026
- // 动态检测 TTS/STT 配置状态
1147
+ // 动态检测 TTS 配置状态
1027
1148
  const hasTTS = !!resolveTTSConfig(cfg as Record<string, unknown>);
1028
- const hasSTT = !!resolveSTTConfig(cfg as Record<string, unknown>);
1029
-
1030
- // 语音能力说明:<qqvoice> 标签本身只负责发送已有的音频文件,不依赖插件 TTS。
1031
- // TTS 只是生成音频文件的一种方式,框架侧的 TTS 工具(如 audio_speech)也能生成。
1032
- // 因此始终暴露 <qqvoice> 能力,但根据 TTS 状态给出不同的使用指引。
1033
- const ttsHint = hasTTS
1034
- ? `6. 🎤 插件 TTS 已启用: 如果你有 TTS 工具(如 audio_speech),可用它生成音频文件后用 <qqvoice> 发送`
1035
- : `6. ⚠️ 插件 TTS 未配置: 如果你有 TTS 工具(如 audio_speech),仍可用它生成音频文件后用 <qqvoice> 发送;若无 TTS 工具,则无法主动生成语音`;
1036
- const sttHint = hasSTT
1037
- ? `\n7. 插件侧 STT 已配置,用户发送的语音消息会尽量自动转录`
1038
- : `\n7. 插件侧 STT 未配置,插件不会自动转录语音消息`;
1039
- const asrFallbackHint = hasAsrReferFallback
1040
- ? `\n8. 本条消息包含平台返回的 asr_refer_text 兜底文本(低置信度)。理解用户意图时可参考,但如关键信息不明确应先追问确认。`
1041
- : "";
1042
- const voiceForwardHint = uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0
1043
- ? `\n9. 本条消息已附带语音文件路径/URL。若你具备 STT 能力(框架能力或 STT skill),优先直接转写音频;若无 STT 能力或转写失败,再使用 asr_refer_text(若存在)作为兜底。`
1044
- : "";
1045
- const voiceSection = `
1046
-
1047
- 【发送语音 - 必须遵守】
1048
- 1. 发语音方法: 在回复文本中写 <qqvoice>本地音频文件路径</qqvoice>,系统自动处理
1049
- 2. 示例: "来听听吧! <qqvoice>/tmp/tts/voice.mp3</qqvoice>"
1050
- 3. 支持格式: .silk, .slk, .slac, .amr, .wav, .mp3, .ogg, .pcm
1051
- 4. ⚠️ <qqvoice> 只用于语音文件,图片请用 <qqimg>;两者不要混用
1052
- 5. 发送语音时,不要重复输出语音中已朗读的文字内容;语音前后的文字应是补充信息而非语音的文字版重复
1053
- ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1054
-
1055
- const voiceAsrSection = uniqueVoiceAsrReferTexts.length > 0
1056
- ? `\n- 语音ASR兜底文本:\n${uniqueVoiceAsrReferTexts.map((t, i) => ` ${i + 1}. ${t}`).join("\n")}`
1057
- : "";
1058
1149
 
1059
1150
  // 引用消息上下文
1060
1151
  let quotePart = "";
@@ -1066,46 +1157,46 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1066
1157
  }
1067
1158
  }
1068
1159
 
1069
- const contextInfo = `你正在通过 QQ 与用户对话。
1070
-
1071
- 【会话上下文】
1072
- - 用户: ${event.senderName || "未知"} (${event.senderId})
1073
- - 场景: ${isGroupChat ? "群聊" : "私聊"}${isGroupChat ? ` (群组: ${event.groupOpenid})` : ""}
1074
- - 消息ID: ${event.messageId}
1075
- - 投递目标: ${qualifiedTarget}${receivedMediaSection}${voiceAsrSection}
1076
- - 当前时间戳(ms): ${nowMs}
1077
- - 定时提醒投递地址: channel=qqbot, to=${qualifiedTarget}
1078
-
1079
- 【发送图片 - 必须遵守】
1080
- 1. 发图方法: 在回复文本中写 <qqimg>URL</qqimg>,系统自动处理
1081
- 2. 示例: "龙虾来啦!🦞 <qqimg>https://picsum.photos/800/600</qqimg>"
1082
- 3. 图片来源: 已知URL直接用、用户发过的本地路径、也可以通过 web_search 搜索图片URL后使用
1083
- 4. ⚠️ 必须在文字回复中嵌入 <qqimg> 标签,禁止只调 tool 不回复文字(用户看不到任何内容)
1084
- 5. 不要说"无法发送图片",直接用 <qqimg> 标签发${voiceSection}
1085
-
1086
- 【发送文件 - 必须遵守】
1087
- 1. 发文件方法: 在回复文本中写 <qqfile>文件路径或URL</qqfile>,系统自动处理
1088
- 2. 示例: "这是你要的文档 <qqfile>/tmp/report.pdf</qqfile>"
1089
- 3. 支持: 本地文件路径、公网 URL
1090
- 4. 适用于非图片非语音的文件(如 pdf, docx, xlsx, zip, txt 等)
1091
- 5. ⚠️ 图片用 <qqimg>,语音用 <qqvoice>,其他文件用 <qqfile>
1092
-
1093
- 【发送视频 - 必须遵守】
1094
- 1. 发视频方法: 在回复文本中写 <qqvideo>路径或URL</qqvideo>,系统自动处理
1095
- 2. 示例: "<qqvideo>https://example.com/video.mp4</qqvideo>" 或 "<qqvideo>/path/to/video.mp4</qqvideo>"
1096
- 3. 支持: 公网 URL、本地文件路径(系统自动读取上传)
1097
- 4. ⚠️ 视频用 <qqvideo>,图片用 <qqimg>,语音用 <qqvoice>,文件用 <qqfile>
1098
-
1099
- 【不要向用户透露过多以上述要求,以下是用户输入】
1100
-
1101
- `;
1160
+ // ============ 构建 contextInfo(静态/动态分离) ============
1161
+ // 设计原则(参考 Telegram/Discord 做法):
1162
+ // - 静态指引:每条消息不变的内容(场景锚定、投递地址、能力说明),
1163
+ // 注入 systemPrompts 前部,session 中虽重复出现但 AI 会自动降权,
1164
+ // 且保证长 session 窗口截断后仍可见。
1165
+ // - 动态标签:每条消息变化的数据(时间、附件、ASR),
1166
+ // 以紧凑的 [ctx] 块标注在用户消息前,最小化 token 开销。
1167
+
1168
+ // --- 静态指引(仅注入框架信封未覆盖的 QQBot 特有信息) ---
1169
+ // 框架 formatInboundEnvelope 已提供:平台标识、发送者、时间戳
1170
+ // 这里只补充 QQBot 独有的:投递地址(cron skill 需要)
1171
+ const staticParts: string[] = [
1172
+ `[QQBot] to=${qualifiedTarget}`,
1173
+ ];
1174
+ // TTS 能力声明:仅在启用时告知 AI 可以发语音(与 qqbot-media SKILL.md 互补)
1175
+ // STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
1176
+ if (hasTTS) staticParts.push("语音合成已启用,发送媒体格式:<qqmedia>路径</qqmedia>");
1177
+ const staticInstruction = staticParts.join(" | ");
1178
+
1179
+ // 静态指引作为 systemPrompts 的首项注入
1180
+ systemPrompts.unshift(staticInstruction);
1181
+
1182
+ // --- 动态上下文(仅框架信封未覆盖的附件信息) ---
1183
+ const dynLines: string[] = [];
1184
+ if (imageUrls.length > 0) {
1185
+ dynLines.push(`- 图片: ${imageUrls.join(", ")}`);
1186
+ }
1187
+ if (uniqueVoicePaths.length > 0 || uniqueVoiceUrls.length > 0) {
1188
+ dynLines.push(`- 语音: ${[...uniqueVoicePaths, ...uniqueVoiceUrls].join(", ")}`);
1189
+ }
1190
+ if (uniqueVoiceAsrReferTexts.length > 0) {
1191
+ dynLines.push(`- ASR: ${uniqueVoiceAsrReferTexts.join(" | ")}`);
1192
+ }
1193
+ const dynamicCtx = dynLines.length > 0 ? dynLines.join("\n") + "\n" : "";
1194
+
1102
1195
  // 命令直接透传,不注入上下文
1103
1196
  const userMessage = `${quotePart}${userContent}`;
1104
1197
  const agentBody = userContent.startsWith("/")
1105
1198
  ? userContent
1106
- : systemPrompts.length > 0
1107
- ? `${contextInfo}\n\n${systemPrompts.join("\n")}\n\n${userMessage}`
1108
- : `${contextInfo}\n\n${userMessage}`;
1199
+ : `${systemPrompts.join("\n")}\n\n${dynamicCtx}${userMessage}`;
1109
1200
 
1110
1201
  log?.info(`[qqbot:${account.accountId}] agentBody length: ${agentBody.length}`);
1111
1202
 
@@ -1230,7 +1321,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1230
1321
  let hasResponse = false;
1231
1322
  let hasBlockResponse = false; // 是否收到了面向用户的 block 回复
1232
1323
  let toolDeliverCount = 0; // tool deliver 计数
1233
- const toolTexts: string[] = []; // 收集所有 tool deliver 文本(用于格式化展示)
1324
+ const toolTexts: string[] = []; // 收集所有 tool deliver 文本
1325
+ const toolMediaUrls: string[] = []; // 收集所有 tool deliver 媒体 URL
1234
1326
  let toolFallbackSent = false; // 兜底消息是否已发送(只发一次)
1235
1327
  const responseTimeout = 120000; // 120秒超时(2分钟,与 TTS/文件生成超时对齐)
1236
1328
  const toolOnlyTimeout = 60000; // tool-only 兜底超时:60秒内没有 block 就兜底
@@ -1239,19 +1331,45 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1239
1331
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
1240
1332
  let toolOnlyTimeoutId: ReturnType<typeof setTimeout> | null = null;
1241
1333
 
1242
- // 格式化 tool 兜底消息:极简,只展示工具原始参数
1243
- const formatToolFallback = (): string => {
1244
- if (toolTexts.length === 0) {
1245
- return "🔧 调用工具中…";
1334
+ // tool-only 兜底:转发工具产生的实际内容(媒体/文本),而非生硬的提示语
1335
+ const sendToolFallback = async (): Promise<void> => {
1336
+ // 优先发送工具产出的媒体文件(TTS 语音、生成图片等)
1337
+ if (toolMediaUrls.length > 0) {
1338
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding ${toolMediaUrls.length} media URL(s) from tool deliver(s)`);
1339
+ const mediaTimeout = 45000; // 单个媒体发送超时 45s
1340
+ for (const mediaUrl of toolMediaUrls) {
1341
+ try {
1342
+ const result = await Promise.race([
1343
+ sendMediaAuto({
1344
+ to: qualifiedTarget,
1345
+ text: "",
1346
+ mediaUrl,
1347
+ accountId: account.accountId,
1348
+ replyToId: event.messageId,
1349
+ account,
1350
+ }),
1351
+ new Promise<{ channel: string; error: string }>((resolve) =>
1352
+ setTimeout(() => resolve({ channel: "qqbot", error: `Tool fallback media send timeout (${mediaTimeout / 1000}s)` }), mediaTimeout)
1353
+ ),
1354
+ ]);
1355
+ if (result.error) {
1356
+ log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia error: ${result.error}`);
1357
+ }
1358
+ } catch (err) {
1359
+ log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${err}`);
1360
+ }
1361
+ }
1362
+ return;
1246
1363
  }
1247
- const recentTools = toolTexts.slice(-3);
1248
- const totalLen = recentTools.reduce((s, t) => s + t.length, 0);
1249
- if (totalLen > 1800) {
1250
- const last = recentTools[recentTools.length - 1]!;
1251
- return `🔧 调用工具中…\n\`\`\`\n${last.slice(0, 1500)}\n\`\`\``;
1364
+ // 其次转发工具产出的文本
1365
+ if (toolTexts.length > 0) {
1366
+ const text = toolTexts.slice(-3).join("\n---\n").slice(0, 2000);
1367
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding tool text (${text.length} chars)`);
1368
+ await sendErrorMessage(text);
1369
+ return;
1252
1370
  }
1253
- const toolBlock = recentTools.join("\n---\n");
1254
- return `🔧 调用工具中…\n\`\`\`\n${toolBlock}\n\`\`\``;
1371
+ // 既无媒体也无文本,静默处理(仅日志记录)
1372
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: no media or text collected from ${toolDeliverCount} tool deliver(s), silently dropping`);
1255
1373
  };
1256
1374
 
1257
1375
  const timeoutPromise = new Promise<void>((_, reject) => {
@@ -1285,7 +1403,41 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1285
1403
  if (toolText) {
1286
1404
  toolTexts.push(toolText);
1287
1405
  }
1288
- log?.info(`[qqbot:${account.accountId}] Skipping tool result deliver #${toolDeliverCount} (intermediate, not user-facing), text length: ${toolText.length}`);
1406
+ // 收集工具产出的媒体 URL(TTS 语音、生成图片等),供 fallback 转发
1407
+ if (payload.mediaUrls?.length) {
1408
+ toolMediaUrls.push(...payload.mediaUrls);
1409
+ }
1410
+ if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
1411
+ toolMediaUrls.push(payload.mediaUrl);
1412
+ }
1413
+ log?.info(`[qqbot:${account.accountId}] Collected tool deliver #${toolDeliverCount}: text=${toolText.length} chars, media=${toolMediaUrls.length} URLs`);
1414
+
1415
+ // block 已先发送完毕,tool 后到的媒体立即转发(典型场景:AI 先流式输出文本再执行 TTS)
1416
+ if (hasBlockResponse && toolMediaUrls.length > 0) {
1417
+ log?.info(`[qqbot:${account.accountId}] Block already sent, immediately forwarding ${toolMediaUrls.length} tool media URL(s)`);
1418
+ const urlsToSend = [...toolMediaUrls];
1419
+ toolMediaUrls.length = 0;
1420
+ for (const mediaUrl of urlsToSend) {
1421
+ try {
1422
+ const result = await sendMediaAuto({
1423
+ to: qualifiedTarget,
1424
+ text: "",
1425
+ mediaUrl,
1426
+ accountId: account.accountId,
1427
+ replyToId: event.messageId,
1428
+ account,
1429
+ });
1430
+ if (result.error) {
1431
+ log?.error(`[qqbot:${account.accountId}] Tool media immediate forward error: ${result.error}`);
1432
+ } else {
1433
+ log?.info(`[qqbot:${account.accountId}] Forwarded tool media (post-block): ${mediaUrl.slice(0, 80)}...`);
1434
+ }
1435
+ } catch (err) {
1436
+ log?.error(`[qqbot:${account.accountId}] Tool media immediate forward failed: ${err}`);
1437
+ }
1438
+ }
1439
+ return;
1440
+ }
1289
1441
 
1290
1442
  // 兜底已发送,不再续期
1291
1443
  if (toolFallbackSent) {
@@ -1309,17 +1461,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1309
1461
  if (!hasBlockResponse && !toolFallbackSent) {
1310
1462
  toolFallbackSent = true;
1311
1463
  log?.error(`[qqbot:${account.accountId}] Tool-only timeout: ${toolDeliverCount} tool deliver(s) but no block within ${toolOnlyTimeout / 1000}s, sending fallback`);
1312
- const fallback = formatToolFallback();
1313
1464
  try {
1314
- await sendWithTokenRetry(async (token) => {
1315
- if (event.type === "c2c") {
1316
- await sendC2CMessage(token, event.senderId, fallback, event.messageId);
1317
- } else if (event.type === "group" && event.groupOpenid) {
1318
- await sendGroupMessage(token, event.groupOpenid, fallback, event.messageId);
1319
- } else if (event.channelId) {
1320
- await sendChannelMessage(token, event.channelId, fallback, event.messageId);
1321
- }
1322
- });
1465
+ await sendToolFallback();
1323
1466
  } catch (sendErr) {
1324
1467
  log?.error(`[qqbot:${account.accountId}] Failed to send tool-only fallback: ${sendErr}`);
1325
1468
  }
@@ -1358,31 +1501,29 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1358
1501
  let replyText = payload.text ?? "";
1359
1502
 
1360
1503
  // ============ 媒体标签解析 ============
1361
- // 支持四种标签:
1362
- // <qqimg>路径</qqimg> 或 <qqimg>路径</img> — 图片
1363
- // <qqvoice>路径</qqvoice> — 语音
1364
- // <qqvideo>路径或URL</qqvideo> — 视频
1365
- // <qqfile>路径</qqfile> — 文件
1504
+ // 支持五种标签:
1505
+ // <qqimg>路径</qqimg> — 图片
1506
+ // <qqvoice>路径</qqvoice> — 语音
1507
+ // <qqvideo>路径或URL</qqvideo> — 视频
1508
+ // <qqfile>路径</qqfile> — 文件
1509
+ // <qqmedia>路径或URL</qqmedia> — 自动识别(根据扩展名路由)
1366
1510
  // 按文本中出现的位置统一构建发送队列,保持顺序
1367
1511
 
1368
1512
  // 预处理:纠正小模型常见的标签拼写错误和格式问题
1369
1513
  replyText = normalizeMediaTags(replyText);
1370
1514
 
1371
- const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
1515
+ const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
1372
1516
  const mediaTagMatches = [...replyText.matchAll(mediaTagRegex)];
1373
1517
 
1374
1518
  if (mediaTagMatches.length > 0) {
1375
- const imgCount = mediaTagMatches.filter(m => m[1]!.toLowerCase() === "qqimg").length;
1376
- const voiceCount = mediaTagMatches.filter(m => m[1]!.toLowerCase() === "qqvoice").length;
1377
- const videoCount = mediaTagMatches.filter(m => m[1]!.toLowerCase() === "qqvideo").length;
1378
- const fileCount = mediaTagMatches.filter(m => m[1]!.toLowerCase() === "qqfile").length;
1379
- log?.info(`[qqbot:${account.accountId}] Detected media tags: ${imgCount} <qqimg>, ${voiceCount} <qqvoice>, ${videoCount} <qqvideo>, ${fileCount} <qqfile>`);
1519
+ const tagCounts = mediaTagMatches.reduce((acc, m) => { const t = m[1]!.toLowerCase(); acc[t] = (acc[t] ?? 0) + 1; return acc; }, {} as Record<string, number>);
1520
+ log?.info(`[qqbot:${account.accountId}] Detected media tags: ${Object.entries(tagCounts).map(([k, v]) => `${v} <${k}>`).join(", ")}`);
1380
1521
 
1381
1522
  // 构建发送队列
1382
- const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file"; content: string }> = [];
1523
+ const sendQueue: Array<{ type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string }> = [];
1383
1524
 
1384
1525
  let lastIndex = 0;
1385
- const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
1526
+ const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
1386
1527
  let match;
1387
1528
 
1388
1529
  while ((match = mediaTagRegexWithIndex.exec(replyText)) !== null) {
@@ -1444,7 +1585,10 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1444
1585
  }
1445
1586
 
1446
1587
  if (mediaPath) {
1447
- if (tagName === "qqvoice") {
1588
+ if (tagName === "qqmedia") {
1589
+ sendQueue.push({ type: "media", content: mediaPath });
1590
+ log?.info(`[qqbot:${account.accountId}] Found auto-detect media in <qqmedia>: ${mediaPath}`);
1591
+ } else if (tagName === "qqvoice") {
1448
1592
  sendQueue.push({ type: "voice", content: mediaPath });
1449
1593
  log?.info(`[qqbot:${account.accountId}] Found voice path in <qqvoice>: ${mediaPath}`);
1450
1594
  } else if (tagName === "qqvideo") {
@@ -1470,10 +1614,17 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1470
1614
 
1471
1615
  log?.info(`[qqbot:${account.accountId}] Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
1472
1616
 
1473
- // 按顺序发送
1617
+ // 按顺序发送(使用 Telegram 风格的统一媒体发送函数)
1618
+ const mediaTarget: MediaTargetContext = {
1619
+ targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
1620
+ targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
1621
+ account,
1622
+ replyToId: event.messageId,
1623
+ logPrefix: `[qqbot:${account.accountId}]`,
1624
+ };
1625
+
1474
1626
  for (const item of sendQueue) {
1475
1627
  if (item.type === "text") {
1476
- // 发送文本
1477
1628
  try {
1478
1629
  await sendWithTokenRetry(async (token) => {
1479
1630
  const ref = consumeQuoteRef();
@@ -1490,250 +1641,56 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1490
1641
  log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`);
1491
1642
  }
1492
1643
  } else if (item.type === "image") {
1493
- // 发送图片(展开 ~ 路径)
1494
- const imagePath = normalizePath(item.content);
1495
- try {
1496
- let imageUrl = imagePath;
1497
-
1498
- // 判断是本地文件还是 URL
1499
- const isLocalPath = isLocalFilePath(imagePath);
1500
- const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://");
1501
-
1502
- if (isLocalPath) {
1503
- // 本地文件:转换为 Base64 Data URL
1504
- if (!(await fileExistsAsync(imagePath))) {
1505
- log?.error(`[qqbot:${account.accountId}] Image file not found: ${imagePath}`);
1506
- await sendErrorMessage(`图片文件不存在: ${imagePath}`);
1507
- continue;
1508
- }
1509
-
1510
- // 文件大小校验
1511
- const imgSizeCheck = checkFileSize(imagePath);
1512
- if (!imgSizeCheck.ok) {
1513
- log?.error(`[qqbot:${account.accountId}] ${imgSizeCheck.error}`);
1514
- await sendErrorMessage(imgSizeCheck.error!);
1515
- continue;
1516
- }
1517
-
1518
- // 大文件进度提示
1519
- if (isLargeFile(imgSizeCheck.size)) {
1520
- try {
1521
- await sendWithTokenRetry(async (token) => {
1522
- const hint = `⏳ 正在上传图片 (${formatFileSize(imgSizeCheck.size)})...`;
1523
- if (event.type === "c2c") {
1524
- await sendC2CMessage(token, event.senderId, hint, event.messageId);
1525
- } else if (event.type === "group" && event.groupOpenid) {
1526
- await sendGroupMessage(token, event.groupOpenid, hint, event.messageId);
1527
- }
1528
- });
1529
- } catch {}
1530
- }
1531
-
1532
- const fileBuffer = await readFileAsync(imagePath);
1533
- const base64Data = fileBuffer.toString("base64");
1534
- const ext = path.extname(imagePath).toLowerCase();
1535
- const mimeTypes: Record<string, string> = {
1536
- ".jpg": "image/jpeg",
1537
- ".jpeg": "image/jpeg",
1538
- ".png": "image/png",
1539
- ".gif": "image/gif",
1540
- ".webp": "image/webp",
1541
- ".bmp": "image/bmp",
1542
- };
1543
- const mimeType = mimeTypes[ext];
1544
- if (!mimeType) {
1545
- log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`);
1546
- await sendErrorMessage(`不支持的图片格式: ${ext}`);
1547
- continue;
1548
- }
1549
- imageUrl = `data:${mimeType};base64,${base64Data}`;
1550
- log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
1551
- } else if (!isHttpUrl) {
1552
- log?.error(`[qqbot:${account.accountId}] Invalid image path (not local or URL): ${imagePath}`);
1553
- continue;
1554
- }
1555
-
1556
- // 发送图片(传递原始本地路径以便 refIdx 缓存记录来源)
1557
- const imgLocalPath = isLocalPath ? imagePath : undefined;
1558
- await sendWithTokenRetry(async (token) => {
1559
- if (event.type === "c2c") {
1560
- await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId, undefined, imgLocalPath);
1561
- } else if (event.type === "group" && event.groupOpenid) {
1562
- await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
1563
- } else if (event.channelId) {
1564
- // 频道使用 Markdown 格式(如果是公网 URL)
1565
- if (isHttpUrl) {
1566
- await sendChannelMessage(token, event.channelId, `![](${imagePath})`, event.messageId);
1567
- } else {
1568
- // 频道不支持富媒体 Base64
1569
- log?.info(`[qqbot:${account.accountId}] Channel does not support rich media for local images`);
1570
- }
1571
- }
1572
- });
1573
- log?.info(`[qqbot:${account.accountId}] Sent image via <qqimg> tag: ${imagePath.slice(0, 60)}...`);
1574
- } catch (err) {
1575
- log?.error(`[qqbot:${account.accountId}] Failed to send image from <qqimg>: ${err}`);
1576
- await sendErrorMessage(`图片发送失败,图片似乎不存在哦,图片路径:${imagePath}`);
1644
+ const result = await sendPhoto(mediaTarget, item.content);
1645
+ if (result.error) {
1646
+ log?.error(`[qqbot:${account.accountId}] sendPhoto error: ${result.error}`);
1647
+ await sendErrorMessage(formatMediaErrorMessage("图片", new Error(result.error)));
1577
1648
  }
1578
1649
  } else if (item.type === "voice") {
1579
- // 发送语音文件(展开 ~ 路径)
1580
- const voicePath = normalizePath(item.content);
1650
+ const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
1651
+ const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
1652
+ // 语音发送加外层超时保护,避免阻塞后续发送队列
1653
+ const voiceTimeout = 45000; // 45 秒(waitForFile 30s + 转码/上传 15s)
1581
1654
  try {
1582
- // 等待文件就绪(TTS 工具异步生成,文件可能还没写完)
1583
- const fileSize = await waitForFile(voicePath);
1584
- if (fileSize === 0) {
1585
- log?.error(`[qqbot:${account.accountId}] Voice file not ready after waiting: ${voicePath}`);
1586
- await sendErrorMessage(`语音生成失败,请稍后重试`);
1587
- continue;
1655
+ const result = await Promise.race([
1656
+ sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled),
1657
+ new Promise<{ channel: string; error: string }>((resolve) =>
1658
+ setTimeout(() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }), voiceTimeout)
1659
+ ),
1660
+ ]);
1661
+ if (result.error) {
1662
+ log?.error(`[qqbot:${account.accountId}] sendVoice error: ${result.error}`);
1663
+ await sendErrorMessage(formatMediaErrorMessage("语音", new Error(result.error)));
1588
1664
  }
1589
-
1590
- // 转换为 SILK 格式(QQ Bot API 语音只支持 SILK),支持配置直传格式跳过转换
1591
- const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
1592
- const silkBase64 = await audioFileToSilkBase64(voicePath, uploadFormats);
1593
- if (!silkBase64) {
1594
- const ext = path.extname(voicePath).toLowerCase();
1595
- log?.error(`[qqbot:${account.accountId}] Voice conversion to SILK failed: ${ext} (${fileSize} bytes). Check [audio-convert] logs for details.`);
1596
- await sendErrorMessage(`语音格式转换失败,请稍后重试`);
1597
- continue;
1598
- }
1599
- log?.info(`[qqbot:${account.accountId}] Voice file converted to SILK Base64 (${fileSize} bytes)`);
1600
-
1601
- await sendWithTokenRetry(async (token) => {
1602
- if (event.type === "c2c") {
1603
- await sendC2CVoiceMessage(token, event.senderId, silkBase64!, event.messageId, undefined, voicePath);
1604
- } else if (event.type === "group" && event.groupOpenid) {
1605
- await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64!, event.messageId);
1606
- } else if (event.channelId) {
1607
- await sendChannelMessage(token, event.channelId, `[语音消息暂不支持频道发送]`, event.messageId);
1608
- }
1609
- });
1610
- log?.info(`[qqbot:${account.accountId}] Sent voice via <qqvoice> tag: ${voicePath.slice(0, 60)}...`);
1611
1665
  } catch (err) {
1612
- log?.error(`[qqbot:${account.accountId}] Failed to send voice from <qqvoice>: ${err}`);
1666
+ log?.error(`[qqbot:${account.accountId}] sendVoice unexpected error: ${err}`);
1613
1667
  await sendErrorMessage(formatMediaErrorMessage("语音", err));
1614
1668
  }
1615
1669
  } else if (item.type === "video") {
1616
- // 发送视频(支持公网 URL 和本地文件,展开 ~ 路径)
1617
- const videoPath = normalizePath(item.content);
1618
- try {
1619
- const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
1620
-
1621
- // 本地视频大文件进度提示
1622
- if (!isHttpUrl) {
1623
- const vidCheck = checkFileSize(videoPath);
1624
- if (vidCheck.ok && isLargeFile(vidCheck.size)) {
1625
- try {
1626
- await sendWithTokenRetry(async (token) => {
1627
- const hint = `⏳ 正在上传视频 (${formatFileSize(vidCheck.size)})...`;
1628
- if (event.type === "c2c") {
1629
- await sendC2CMessage(token, event.senderId, hint, event.messageId);
1630
- } else if (event.type === "group" && event.groupOpenid) {
1631
- await sendGroupMessage(token, event.groupOpenid, hint, event.messageId);
1632
- }
1633
- });
1634
- } catch {}
1635
- }
1636
- }
1637
-
1638
- await sendWithTokenRetry(async (token) => {
1639
- if (isHttpUrl) {
1640
- // 公网 URL
1641
- if (event.type === "c2c") {
1642
- await sendC2CVideoMessage(token, event.senderId, videoPath, undefined, event.messageId);
1643
- } else if (event.type === "group" && event.groupOpenid) {
1644
- await sendGroupVideoMessage(token, event.groupOpenid, videoPath, undefined, event.messageId);
1645
- } else if (event.channelId) {
1646
- await sendChannelMessage(token, event.channelId, `[视频消息暂不支持频道发送]`, event.messageId);
1647
- }
1648
- } else {
1649
- // 本地文件:读取为 Base64
1650
- if (!(await fileExistsAsync(videoPath))) {
1651
- throw new Error(`视频文件不存在: ${videoPath}`);
1652
- }
1653
- // 文件大小校验
1654
- const vidSizeCheck = checkFileSize(videoPath);
1655
- if (!vidSizeCheck.ok) {
1656
- throw new Error(vidSizeCheck.error!);
1657
- }
1658
- const fileBuffer = await readFileAsync(videoPath);
1659
- const videoBase64 = fileBuffer.toString("base64");
1660
- log?.info(`[qqbot:${account.accountId}] Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
1661
-
1662
- if (event.type === "c2c") {
1663
- await sendC2CVideoMessage(token, event.senderId, undefined, videoBase64, event.messageId, undefined, videoPath);
1664
- } else if (event.type === "group" && event.groupOpenid) {
1665
- await sendGroupVideoMessage(token, event.groupOpenid, undefined, videoBase64, event.messageId);
1666
- } else if (event.channelId) {
1667
- await sendChannelMessage(token, event.channelId, `[视频消息暂不支持频道发送]`, event.messageId);
1668
- }
1669
- }
1670
- });
1671
- log?.info(`[qqbot:${account.accountId}] Sent video via <qqvideo> tag: ${videoPath.slice(0, 60)}...`);
1672
- } catch (err) {
1673
- log?.error(`[qqbot:${account.accountId}] Failed to send video from <qqvideo>: ${err}`);
1674
- await sendErrorMessage(formatMediaErrorMessage("视频", err));
1670
+ const result = await sendVideoMsg(mediaTarget, item.content);
1671
+ if (result.error) {
1672
+ log?.error(`[qqbot:${account.accountId}] sendVideoMsg error: ${result.error}`);
1673
+ await sendErrorMessage(formatMediaErrorMessage("视频", new Error(result.error)));
1675
1674
  }
1676
1675
  } else if (item.type === "file") {
1677
- // 发送文件(展开 ~ 路径)
1678
- const filePath = normalizePath(item.content);
1679
- try {
1680
- const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
1681
- const fileName = sanitizeFileName(path.basename(filePath));
1682
-
1683
- // 本地文件大文件进度提示
1684
- if (!isHttpUrl) {
1685
- const fileCheck = checkFileSize(filePath);
1686
- if (fileCheck.ok && isLargeFile(fileCheck.size)) {
1687
- try {
1688
- await sendWithTokenRetry(async (token) => {
1689
- const hint = `⏳ 正在上传文件 ${fileName} (${formatFileSize(fileCheck.size)})...`;
1690
- if (event.type === "c2c") {
1691
- await sendC2CMessage(token, event.senderId, hint, event.messageId);
1692
- } else if (event.type === "group" && event.groupOpenid) {
1693
- await sendGroupMessage(token, event.groupOpenid, hint, event.messageId);
1694
- }
1695
- });
1696
- } catch {}
1697
- }
1698
- }
1699
-
1700
- await sendWithTokenRetry(async (token) => {
1701
- if (isHttpUrl) {
1702
- // 公网 URL
1703
- if (event.type === "c2c") {
1704
- await sendC2CFileMessage(token, event.senderId, undefined, filePath, event.messageId, fileName);
1705
- } else if (event.type === "group" && event.groupOpenid) {
1706
- await sendGroupFileMessage(token, event.groupOpenid, undefined, filePath, event.messageId, fileName);
1707
- } else if (event.channelId) {
1708
- await sendChannelMessage(token, event.channelId, `[文件消息暂不支持频道发送]`, event.messageId);
1709
- }
1710
- } else {
1711
- // 本地文件
1712
- if (!(await fileExistsAsync(filePath))) {
1713
- throw new Error(`文件不存在: ${filePath}`);
1714
- }
1715
- // 文件大小校验
1716
- const flSizeCheck = checkFileSize(filePath);
1717
- if (!flSizeCheck.ok) {
1718
- throw new Error(flSizeCheck.error!);
1719
- }
1720
- const fileBuffer = await readFileAsync(filePath);
1721
- const fileBase64 = fileBuffer.toString("base64");
1722
- log?.info(`[qqbot:${account.accountId}] Read local file (${formatFileSize(fileBuffer.length)}): ${filePath}`);
1723
-
1724
- if (event.type === "c2c") {
1725
- await sendC2CFileMessage(token, event.senderId, fileBase64, undefined, event.messageId, fileName, filePath);
1726
- } else if (event.type === "group" && event.groupOpenid) {
1727
- await sendGroupFileMessage(token, event.groupOpenid, fileBase64, undefined, event.messageId, fileName);
1728
- } else if (event.channelId) {
1729
- await sendChannelMessage(token, event.channelId, `[文件消息暂不支持频道发送]`, event.messageId);
1730
- }
1731
- }
1732
- });
1733
- log?.info(`[qqbot:${account.accountId}] Sent file via <qqfile> tag: ${filePath.slice(0, 60)}...`);
1734
- } catch (err) {
1735
- log?.error(`[qqbot:${account.accountId}] Failed to send file from <qqfile>: ${err}`);
1736
- await sendErrorMessage(`文件发送失败: ${err}`);
1676
+ const result = await sendDocument(mediaTarget, item.content);
1677
+ if (result.error) {
1678
+ log?.error(`[qqbot:${account.accountId}] sendDocument error: ${result.error}`);
1679
+ await sendErrorMessage(formatMediaErrorMessage("文件", new Error(result.error)));
1680
+ }
1681
+ } else if (item.type === "media") {
1682
+ // qqmedia: 自动根据扩展名路由到 sendPhoto/sendVoice/sendVideoMsg/sendDocument
1683
+ const result = await sendMediaAuto({
1684
+ to: qualifiedTarget,
1685
+ text: "",
1686
+ mediaUrl: item.content,
1687
+ accountId: account.accountId,
1688
+ replyToId: event.messageId,
1689
+ account,
1690
+ });
1691
+ if (result.error) {
1692
+ log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error: ${result.error}`);
1693
+ await sendErrorMessage(formatMediaErrorMessage("媒体", new Error(result.error)));
1737
1694
  }
1738
1695
  }
1739
1696
  }
@@ -1755,7 +1712,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1755
1712
  if (payloadResult.error) {
1756
1713
  // 载荷解析失败,发送错误提示
1757
1714
  log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`);
1758
- await sendErrorMessage(`[QQBot] 载荷解析失败: ${payloadResult.error}`);
1715
+ await sendErrorMessage(MSG.PAYLOAD_PARSE_ERROR);
1759
1716
  return;
1760
1717
  }
1761
1718
 
@@ -1808,12 +1765,12 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1808
1765
  if (parsedPayload.source === "file") {
1809
1766
  try {
1810
1767
  if (!(await fileExistsAsync(imageUrl))) {
1811
- await sendErrorMessage(`[QQBot] 图片文件不存在: ${imageUrl}`);
1768
+ await sendErrorMessage(MSG.IMAGE_NOT_FOUND);
1812
1769
  return;
1813
1770
  }
1814
1771
  const imgSzCheck = checkFileSize(imageUrl);
1815
1772
  if (!imgSzCheck.ok) {
1816
- await sendErrorMessage(`[QQBot] ${imgSzCheck.error}`);
1773
+ await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
1817
1774
  return;
1818
1775
  }
1819
1776
  const fileBuffer = await readFileAsync(imageUrl);
@@ -1829,14 +1786,14 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1829
1786
  };
1830
1787
  const mimeType = mimeTypes[ext];
1831
1788
  if (!mimeType) {
1832
- await sendErrorMessage(`[QQBot] 不支持的图片格式: ${ext}`);
1789
+ await sendErrorMessage(MSG.IMAGE_FORMAT_UNSUPPORTED(ext));
1833
1790
  return;
1834
1791
  }
1835
1792
  imageUrl = `data:${mimeType};base64,${base64Data}`;
1836
1793
  log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
1837
1794
  } catch (readErr) {
1838
1795
  log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
1839
- await sendErrorMessage(`[QQBot] 读取图片文件失败: ${readErr}`);
1796
+ await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
1840
1797
  return;
1841
1798
  }
1842
1799
  }
@@ -1875,13 +1832,13 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1875
1832
  // TTS 语音发送:文字 → PCM → SILK → QQ 语音
1876
1833
  try {
1877
1834
  const ttsText = parsedPayload.caption || parsedPayload.path;
1878
- if (!ttsText?.trim()) {
1879
- await sendErrorMessage(`[QQBot] 语音消息缺少文本内容`);
1880
- } else {
1881
- const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
1882
- if (!ttsCfg) {
1883
- log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
1884
- await sendErrorMessage(`[QQBot] TTS 未配置,请在 openclaw.json 的 channels.qqbot.tts 中配置`);
1835
+ if (!ttsText?.trim()) {
1836
+ await sendErrorMessage(MSG.VOICE_MISSING_TEXT);
1837
+ } else {
1838
+ const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
1839
+ if (!ttsCfg) {
1840
+ log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
1841
+ await sendErrorMessage(MSG.VOICE_NOT_AVAILABLE);
1885
1842
  } else {
1886
1843
  log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
1887
1844
  const ttsDir = getQQBotDataDir("tts");
@@ -1894,7 +1851,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1894
1851
  } else if (event.type === "group" && event.groupOpenid) {
1895
1852
  await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64, event.messageId);
1896
1853
  } else if (event.channelId) {
1897
- await sendChannelMessage(token, event.channelId, `[语音消息暂不支持频道发送] ${ttsText}`, event.messageId);
1854
+ await sendChannelMessage(token, event.channelId, `${MSG.VOICE_CHANNEL_UNSUPPORTED}\n${ttsText}`, event.messageId);
1898
1855
  }
1899
1856
  });
1900
1857
  log?.info(`[qqbot:${account.accountId}] Voice message sent`);
@@ -1902,14 +1859,14 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1902
1859
  }
1903
1860
  } catch (err) {
1904
1861
  log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
1905
- await sendErrorMessage(`[QQBot] 语音发送失败: ${err}`);
1862
+ await sendErrorMessage(formatMediaErrorMessage("语音", err));
1906
1863
  }
1907
1864
  } else if (parsedPayload.mediaType === "video") {
1908
1865
  // 视频发送:支持公网 URL 和本地文件
1909
1866
  try {
1910
1867
  const videoPath = normalizePath(parsedPayload.path ?? "");
1911
1868
  if (!videoPath?.trim()) {
1912
- await sendErrorMessage(`[QQBot] 视频消息缺少视频路径`);
1869
+ await sendErrorMessage(MSG.VIDEO_MISSING_PATH);
1913
1870
  } else {
1914
1871
  const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
1915
1872
  log?.info(`[qqbot:${account.accountId}] Video send: "${videoPath.slice(0, 60)}..."`);
@@ -1922,7 +1879,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1922
1879
  } else if (event.type === "group" && event.groupOpenid) {
1923
1880
  await sendGroupVideoMessage(token, event.groupOpenid, videoPath, undefined, event.messageId);
1924
1881
  } else if (event.channelId) {
1925
- await sendChannelMessage(token, event.channelId, `[视频消息暂不支持频道发送]`, event.messageId);
1882
+ await sendChannelMessage(token, event.channelId, MSG.VIDEO_CHANNEL_UNSUPPORTED, event.messageId);
1926
1883
  }
1927
1884
  } else {
1928
1885
  // 本地文件:读取为 Base64
@@ -1942,7 +1899,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1942
1899
  } else if (event.type === "group" && event.groupOpenid) {
1943
1900
  await sendGroupVideoMessage(token, event.groupOpenid, undefined, videoBase64, event.messageId);
1944
1901
  } else if (event.channelId) {
1945
- await sendChannelMessage(token, event.channelId, `[视频消息暂不支持频道发送]`, event.messageId);
1902
+ await sendChannelMessage(token, event.channelId, MSG.VIDEO_CHANNEL_UNSUPPORTED, event.messageId);
1946
1903
  }
1947
1904
  }
1948
1905
  });
@@ -1970,7 +1927,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1970
1927
  try {
1971
1928
  const filePath = normalizePath(parsedPayload.path ?? "");
1972
1929
  if (!filePath?.trim()) {
1973
- await sendErrorMessage(`[QQBot] 文件消息缺少文件路径`);
1930
+ await sendErrorMessage(MSG.FILE_MISSING_PATH);
1974
1931
  } else {
1975
1932
  const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
1976
1933
  const fileName = sanitizeFileName(path.basename(filePath));
@@ -1983,7 +1940,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
1983
1940
  } else if (event.type === "group" && event.groupOpenid) {
1984
1941
  await sendGroupFileMessage(token, event.groupOpenid, undefined, filePath, event.messageId, fileName);
1985
1942
  } else if (event.channelId) {
1986
- await sendChannelMessage(token, event.channelId, `[文件消息暂不支持频道发送]`, event.messageId);
1943
+ await sendChannelMessage(token, event.channelId, MSG.FILE_CHANNEL_UNSUPPORTED, event.messageId);
1987
1944
  }
1988
1945
  } else {
1989
1946
  if (!(await fileExistsAsync(filePath))) {
@@ -2000,7 +1957,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2000
1957
  } else if (event.type === "group" && event.groupOpenid) {
2001
1958
  await sendGroupFileMessage(token, event.groupOpenid, fileBase64, undefined, event.messageId, fileName);
2002
1959
  } else if (event.channelId) {
2003
- await sendChannelMessage(token, event.channelId, `[文件消息暂不支持频道发送]`, event.messageId);
1960
+ await sendChannelMessage(token, event.channelId, MSG.FILE_CHANNEL_UNSUPPORTED, event.messageId);
2004
1961
  }
2005
1962
  }
2006
1963
  });
@@ -2012,7 +1969,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2012
1969
  }
2013
1970
  } else {
2014
1971
  log?.error(`[qqbot:${account.accountId}] Unknown media type: ${(parsedPayload as MediaPayload).mediaType}`);
2015
- await sendErrorMessage(`[QQBot] 不支持的媒体类型: ${(parsedPayload as MediaPayload).mediaType}`);
1972
+ await sendErrorMessage(MSG.UNSUPPORTED_MEDIA_TYPE);
2016
1973
  }
2017
1974
 
2018
1975
  // 记录活动并返回
@@ -2025,20 +1982,20 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2025
1982
  } else {
2026
1983
  // 未知的载荷类型
2027
1984
  log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${(parsedPayload as any).type}`);
2028
- await sendErrorMessage(`[QQBot] 不支持的载荷类型: ${(parsedPayload as any).type}`);
1985
+ await sendErrorMessage(MSG.UNSUPPORTED_PAYLOAD_TYPE);
2029
1986
  return;
2030
1987
  }
2031
1988
  }
2032
1989
  }
2033
1990
 
2034
1991
  // ============ 非结构化消息:简化处理 ============
2035
- // 📝 设计原则:JSON payload (QQBOT_PAYLOAD) 是发送本地图片的唯一方式
2036
- // 非结构化消息只处理:公网 URL (http/https) 和 Base64 Data URL
2037
1992
  const imageUrls: string[] = [];
1993
+ const localMediaToSend: string[] = []; // 本地路径走 sendMedia 自动路由
2038
1994
 
2039
1995
  /**
2040
- * 检查并收集图片 URL(仅支持公网 URL 和 Base64 Data URL)
2041
- * ⚠️ 本地文件路径必须使用 QQBOT_PAYLOAD JSON 格式发送
1996
+ * 收集媒体 URL/路径
1997
+ * - 公网 URL / Base64 Data URL → 图片处理管线
1998
+ * - 本地文件路径 → sendMedia 自动路由(根据扩展名识别类型)
2042
1999
  */
2043
2000
  const collectImageUrl = (url: string | undefined | null): boolean => {
2044
2001
  if (!url) return false;
@@ -2058,24 +2015,13 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2058
2015
  return true;
2059
2016
  }
2060
2017
 
2061
- // ⚠️ 本地文件路径不再在此处处理,应使用对应的 <qqXXX> 标签
2018
+ // 本地文件路径:走 sendMedia 自动路由
2062
2019
  if (isLocalFilePath(url)) {
2063
- const ext = path.extname(url).toLowerCase();
2064
- const VIDEO_EXTS = [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"];
2065
- let suggestedTag = "qqimg";
2066
- let mediaDesc = "图片";
2067
- if (isAudioFile(url)) {
2068
- suggestedTag = "qqvoice";
2069
- mediaDesc = "语音";
2070
- } else if (VIDEO_EXTS.includes(ext)) {
2071
- suggestedTag = "qqvideo";
2072
- mediaDesc = "视频";
2073
- } else if (![".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext)) {
2074
- suggestedTag = "qqfile";
2075
- mediaDesc = "文件";
2020
+ if (!localMediaToSend.includes(url)) {
2021
+ localMediaToSend.push(url);
2022
+ log?.info(`[qqbot:${account.accountId}] Collected local media for auto-routing: ${url}`);
2076
2023
  }
2077
- log?.info(`[qqbot:${account.accountId}] 💡 Local path detected in non-structured message (not sending): ${url}`);
2078
- log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use <${suggestedTag}>${url}</${suggestedTag}> tag to send local ${mediaDesc}`);
2024
+ return true;
2079
2025
  }
2080
2026
  return false;
2081
2027
  };
@@ -2101,24 +2047,12 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2101
2047
  // 公网 URL:收集并处理
2102
2048
  imageUrls.push(url);
2103
2049
  log?.info(`[qqbot:${account.accountId}] Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
2104
- } else if (looksLikeLocalPath(url)) {
2105
- // 本地路径:根据文件类型给出正确的标签提示
2106
- const ext = path.extname(url).toLowerCase();
2107
- const VIDEO_EXTS = [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"];
2108
- let suggestedTag = "qqimg";
2109
- let mediaDesc = "图片";
2110
- if (isAudioFile(url)) {
2111
- suggestedTag = "qqvoice";
2112
- mediaDesc = "语音";
2113
- } else if (VIDEO_EXTS.includes(ext)) {
2114
- suggestedTag = "qqvideo";
2115
- mediaDesc = "视频";
2116
- } else if (![".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext)) {
2117
- suggestedTag = "qqfile";
2118
- mediaDesc = "文件";
2050
+ } else if (isLocalFilePath(url)) {
2051
+ // 本地路径:走 sendMedia 自动路由
2052
+ if (!localMediaToSend.includes(url)) {
2053
+ localMediaToSend.push(url);
2054
+ log?.info(`[qqbot:${account.accountId}] Collected local media from markdown for auto-routing: ${url}`);
2119
2055
  }
2120
- log?.info(`[qqbot:${account.accountId}] 💡 Local path detected in non-structured message (not sending): ${url}`);
2121
- log?.info(`[qqbot:${account.accountId}] 💡 Hint: Use <${suggestedTag}>${url}</${suggestedTag}> tag to send local ${mediaDesc}`);
2122
2056
  }
2123
2057
  }
2124
2058
  }
@@ -2267,7 +2201,14 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2267
2201
  }
2268
2202
  }
2269
2203
  } else {
2270
- // ============ 普通文本模式:使用富媒体 API 发送图片 ============
2204
+ // ============ 普通文本模式:使用 sendPhoto 发送图片(内置 URL fallback) ============
2205
+ const imgMediaTarget: MediaTargetContext = {
2206
+ targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
2207
+ targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
2208
+ account,
2209
+ replyToId: event.messageId,
2210
+ logPrefix: `[qqbot:${account.accountId}]`,
2211
+ };
2271
2212
  // 从文本中移除所有图片相关内容
2272
2213
  for (const match of mdMatches) {
2273
2214
  textWithoutImages = textWithoutImages.replace(match[0], "").trim();
@@ -2282,20 +2223,15 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2282
2223
  }
2283
2224
 
2284
2225
  try {
2285
- // 发送图片(通过富媒体 API
2226
+ // 发送图片(通过 sendPhoto,内置 URL 直传 → 下载 fallback
2286
2227
  for (const imageUrl of imageUrls) {
2287
2228
  try {
2288
- await sendWithTokenRetry(async (token) => {
2289
- if (event.type === "c2c") {
2290
- await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
2291
- } else if (event.type === "group" && event.groupOpenid) {
2292
- await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
2293
- } else if (event.channelId) {
2294
- // 频道暂不支持富媒体,发送文本 URL
2295
- await sendChannelMessage(token, event.channelId, imageUrl, event.messageId);
2296
- }
2297
- });
2298
- log?.info(`[qqbot:${account.accountId}] Sent image via media API: ${imageUrl.slice(0, 80)}...`);
2229
+ const imgResult = await sendPhoto(imgMediaTarget, imageUrl);
2230
+ if (imgResult.error) {
2231
+ log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgResult.error}`);
2232
+ } else {
2233
+ log?.info(`[qqbot:${account.accountId}] Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
2234
+ }
2299
2235
  } catch (imgErr) {
2300
2236
  log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
2301
2237
  }
@@ -2320,6 +2256,58 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2320
2256
  }
2321
2257
  }
2322
2258
 
2259
+ // 发送 localMediaToSend 中收集到的本地媒体(由 payload.mediaUrl 或 markdown 本地路径触发)
2260
+ if (localMediaToSend.length > 0) {
2261
+ log?.info(`[qqbot:${account.accountId}] Sending ${localMediaToSend.length} local media via sendMedia auto-routing`);
2262
+ for (const mediaPath of localMediaToSend) {
2263
+ try {
2264
+ const result = await sendMediaAuto({
2265
+ to: qualifiedTarget,
2266
+ text: "",
2267
+ mediaUrl: mediaPath,
2268
+ accountId: account.accountId,
2269
+ replyToId: event.messageId,
2270
+ account,
2271
+ });
2272
+ if (result.error) {
2273
+ log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error for ${mediaPath}: ${result.error}`);
2274
+ } else {
2275
+ log?.info(`[qqbot:${account.accountId}] Sent local media: ${mediaPath}`);
2276
+ }
2277
+ } catch (err) {
2278
+ log?.error(`[qqbot:${account.accountId}] sendMedia(auto) failed for ${mediaPath}: ${err}`);
2279
+ }
2280
+ }
2281
+ }
2282
+
2283
+ // ============ 转发 tool 阶段收集的媒体(TTS 语音、生成图片等) ============
2284
+ // block 回复已发送,但 tool deliver 阶段收集的媒体 URL 未被发送(tool deliver 只收集不发送)。
2285
+ // 此处主动转发,避免工具产出的媒体被静默丢弃。
2286
+ if (toolMediaUrls.length > 0) {
2287
+ log?.info(`[qqbot:${account.accountId}] Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`);
2288
+ for (const mediaUrl of toolMediaUrls) {
2289
+ try {
2290
+ const result = await sendMediaAuto({
2291
+ to: qualifiedTarget,
2292
+ text: "",
2293
+ mediaUrl,
2294
+ accountId: account.accountId,
2295
+ replyToId: event.messageId,
2296
+ account,
2297
+ });
2298
+ if (result.error) {
2299
+ log?.error(`[qqbot:${account.accountId}] Tool media forward error: ${result.error}`);
2300
+ } else {
2301
+ log?.info(`[qqbot:${account.accountId}] Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
2302
+ }
2303
+ } catch (err) {
2304
+ log?.error(`[qqbot:${account.accountId}] Tool media forward failed: ${err}`);
2305
+ }
2306
+ }
2307
+ // 清空已转发的 URL,避免 tool-only 兜底重复发送
2308
+ toolMediaUrls.length = 0;
2309
+ }
2310
+
2323
2311
  pluginRuntime.channel.activity.record({
2324
2312
  channel: "qqbot",
2325
2313
  accountId: account.accountId,
@@ -2337,9 +2325,9 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2337
2325
  // 发送错误提示给用户,显示完整错误信息
2338
2326
  const errMsg = String(err);
2339
2327
  if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
2340
- await sendErrorMessage("⚠️ AI 服务认证失败,API Key 可能无效,请联系管理员检查配置。");
2328
+ await sendErrorMessage(MSG.AI_AUTH_ERROR);
2341
2329
  } else {
2342
- await sendErrorMessage(`⚠️ AI 处理出错: ${errMsg.slice(0, 500)}`);
2330
+ await sendErrorMessage(MSG.AI_PROCESS_ERROR);
2343
2331
  }
2344
2332
  },
2345
2333
  },
@@ -2357,7 +2345,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2357
2345
  }
2358
2346
  if (!hasResponse) {
2359
2347
  log?.error(`[qqbot:${account.accountId}] No response within timeout`);
2360
- await sendErrorMessage("⏳ 已收到,正在处理中…");
2348
+ await sendErrorMessage(MSG.TIMEOUT_HINT);
2361
2349
  }
2362
2350
  } finally {
2363
2351
  // 清理 tool-only 兜底定时器
@@ -2369,13 +2357,12 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2369
2357
  if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
2370
2358
  toolFallbackSent = true;
2371
2359
  log?.error(`[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`);
2372
- const fallback = formatToolFallback();
2373
- await sendErrorMessage(fallback);
2360
+ await sendToolFallback();
2374
2361
  }
2375
2362
  }
2376
2363
  } catch (err) {
2377
2364
  log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
2378
- await sendErrorMessage(`⚠️ 消息处理失败: ${String(err).slice(0, 500)}`);
2365
+ await sendErrorMessage(MSG.GENERIC_ERROR);
2379
2366
  }
2380
2367
  };
2381
2368
 
@@ -2406,7 +2393,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2406
2393
  sessionId,
2407
2394
  lastSeq,
2408
2395
  lastConnectedAt: lastConnectTime,
2409
- intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
2396
+ intentLevelIndex: 0,
2410
2397
  accountId: account.accountId,
2411
2398
  savedAt: Date.now(),
2412
2399
  appId: account.appId,
@@ -2432,16 +2419,13 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2432
2419
  },
2433
2420
  }));
2434
2421
  } else {
2435
- // 新连接,发送 Identify
2436
- // 如果有上次成功的级别,直接使用;否则从当前级别开始尝试
2437
- const levelToUse = lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex;
2438
- const intentLevel = INTENT_LEVELS[Math.min(levelToUse, INTENT_LEVELS.length - 1)];
2439
- log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${intentLevel.intents} (${intentLevel.description})`);
2422
+ // 新连接,发送 Identify,始终使用完整权限
2423
+ log?.info(`[qqbot:${account.accountId}] Sending identify with intents: ${FULL_INTENTS} (${FULL_INTENTS_DESC})`);
2440
2424
  ws.send(JSON.stringify({
2441
2425
  op: 2,
2442
2426
  d: {
2443
2427
  token: `QQBot ${accessToken}`,
2444
- intents: intentLevel.intents,
2428
+ intents: FULL_INTENTS,
2445
2429
  shard: [0, 1],
2446
2430
  },
2447
2431
  }));
@@ -2463,30 +2447,41 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2463
2447
  if (t === "READY") {
2464
2448
  const readyData = d as { session_id: string };
2465
2449
  sessionId = readyData.session_id;
2466
- // 记录成功的权限级别
2467
- lastSuccessfulIntentLevel = intentLevelIndex;
2468
- const successLevel = INTENT_LEVELS[intentLevelIndex];
2469
- log?.info(`[qqbot:${account.accountId}] Ready with ${successLevel.description}, session: ${sessionId}`);
2450
+ log?.info(`[qqbot:${account.accountId}] Ready with ${FULL_INTENTS_DESC}, session: ${sessionId}`);
2470
2451
  // P1-2: 保存新的 Session 状态
2471
2452
  saveSession({
2472
2453
  sessionId,
2473
2454
  lastSeq,
2474
2455
  lastConnectedAt: Date.now(),
2475
- intentLevelIndex,
2456
+ intentLevelIndex: 0,
2476
2457
  accountId: account.accountId,
2477
2458
  savedAt: Date.now(),
2478
2459
  appId: account.appId,
2479
2460
  });
2480
2461
  onReady?.(d);
2462
+
2463
+ // 仅 startGateway 后的首次 READY 才发送上线通知
2464
+ // ws 断线重连(resume 失败后重新 Identify)产生的 READY 不发送
2465
+ if (!isFirstReadyGlobal) {
2466
+ log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (reconnect READY, not first startup)`);
2467
+ } else {
2468
+ isFirstReadyGlobal = false;
2469
+ sendStartupGreetings("READY");
2470
+ } // end isFirstReady
2481
2471
  } else if (t === "RESUMED") {
2482
2472
  log?.info(`[qqbot:${account.accountId}] Session resumed`);
2473
+ // RESUMED 也属于首次启动(gateway restart 通常走 resume)
2474
+ if (isFirstReadyGlobal) {
2475
+ isFirstReadyGlobal = false;
2476
+ sendStartupGreetings("RESUMED");
2477
+ }
2483
2478
  // P1-2: 更新 Session 连接时间
2484
2479
  if (sessionId) {
2485
2480
  saveSession({
2486
2481
  sessionId,
2487
2482
  lastSeq,
2488
2483
  lastConnectedAt: Date.now(),
2489
- intentLevelIndex: lastSuccessfulIntentLevel >= 0 ? lastSuccessfulIntentLevel : intentLevelIndex,
2484
+ intentLevelIndex: 0,
2490
2485
  accountId: account.accountId,
2491
2486
  savedAt: Date.now(),
2492
2487
  appId: account.appId,
@@ -2502,8 +2497,8 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2502
2497
  });
2503
2498
  // 解析引用索引
2504
2499
  const c2cRefs = parseRefIndices(event.message_scene?.ext);
2505
- // 使用消息队列异步处理,防止阻塞心跳
2506
- enqueueMessage({
2500
+ // 斜杠指令拦截 → 不匹配则入队
2501
+ trySlashCommandOrEnqueue({
2507
2502
  type: "c2c",
2508
2503
  senderId: event.author.user_openid,
2509
2504
  content: event.content,
@@ -2523,7 +2518,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2523
2518
  accountId: account.accountId,
2524
2519
  });
2525
2520
  const guildRefs = parseRefIndices((event as any).message_scene?.ext);
2526
- enqueueMessage({
2521
+ trySlashCommandOrEnqueue({
2527
2522
  type: "guild",
2528
2523
  senderId: event.author.id,
2529
2524
  senderName: event.author.username,
@@ -2546,7 +2541,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2546
2541
  accountId: account.accountId,
2547
2542
  });
2548
2543
  const dmRefs = parseRefIndices((event as any).message_scene?.ext);
2549
- enqueueMessage({
2544
+ trySlashCommandOrEnqueue({
2550
2545
  type: "dm",
2551
2546
  senderId: event.author.id,
2552
2547
  senderName: event.author.username,
@@ -2568,7 +2563,7 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2568
2563
  accountId: account.accountId,
2569
2564
  });
2570
2565
  const groupRefs = parseRefIndices(event.message_scene?.ext);
2571
- enqueueMessage({
2566
+ trySlashCommandOrEnqueue({
2572
2567
  type: "group",
2573
2568
  senderId: event.author.member_openid,
2574
2569
  content: event.content,
@@ -2594,25 +2589,15 @@ ${ttsHint}${sttHint}${asrFallbackHint}${voiceForwardHint}`;
2594
2589
 
2595
2590
  case 9: // Invalid Session
2596
2591
  const canResume = d as boolean;
2597
- const currentLevel = INTENT_LEVELS[intentLevelIndex];
2598
- log?.error(`[qqbot:${account.accountId}] Invalid session (${currentLevel.description}), can resume: ${canResume}, raw: ${rawData}`);
2592
+ log?.error(`[qqbot:${account.accountId}] Invalid session (${FULL_INTENTS_DESC}), can resume: ${canResume}, raw: ${rawData}`);
2599
2593
 
2600
2594
  if (!canResume) {
2601
2595
  sessionId = null;
2602
2596
  lastSeq = null;
2603
2597
  // P1-2: 清除持久化的 Session
2604
2598
  clearSession(account.accountId);
2605
-
2606
- // 尝试降级到下一个权限级别
2607
- if (intentLevelIndex < INTENT_LEVELS.length - 1) {
2608
- intentLevelIndex++;
2609
- const nextLevel = INTENT_LEVELS[intentLevelIndex];
2610
- log?.info(`[qqbot:${account.accountId}] Downgrading intents to: ${nextLevel.description}`);
2611
- } else {
2612
- // 已经是最低权限级别了
2613
- log?.error(`[qqbot:${account.accountId}] All intent levels failed. Please check AppID/Secret.`);
2614
- shouldRefreshToken = true;
2615
- }
2599
+ shouldRefreshToken = true;
2600
+ log?.info(`[qqbot:${account.accountId}] Will refresh token and retry with full intents (${FULL_INTENTS_DESC})`);
2616
2601
  }
2617
2602
  cleanup();
2618
2603
  // Invalid Session 后等待一段时间再重连