@ryantest/openclaw-qqbot 0.0.2 → 1.6.6-alpha.0

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 (89) hide show
  1. package/README.md +2 -15
  2. package/README.zh.md +3 -16
  3. package/dist/src/admin-resolver.d.ts +12 -6
  4. package/dist/src/admin-resolver.js +69 -34
  5. package/dist/src/api.d.ts +105 -1
  6. package/dist/src/api.js +164 -15
  7. package/dist/src/channel.js +13 -0
  8. package/dist/src/config.js +3 -10
  9. package/dist/src/deliver-debounce.d.ts +74 -0
  10. package/dist/src/deliver-debounce.js +174 -0
  11. package/dist/src/gateway.js +450 -248
  12. package/dist/src/image-server.d.ts +27 -8
  13. package/dist/src/image-server.js +179 -71
  14. package/dist/src/inbound-attachments.d.ts +3 -1
  15. package/dist/src/inbound-attachments.js +28 -14
  16. package/dist/src/outbound-deliver.js +77 -148
  17. package/dist/src/outbound.d.ts +6 -4
  18. package/dist/src/outbound.js +266 -442
  19. package/dist/src/reply-dispatcher.js +4 -4
  20. package/dist/src/request-context.d.ts +18 -0
  21. package/dist/src/request-context.js +30 -0
  22. package/dist/src/slash-commands.js +277 -32
  23. package/dist/src/startup-greeting.d.ts +5 -5
  24. package/dist/src/startup-greeting.js +32 -13
  25. package/dist/src/streaming.d.ts +244 -0
  26. package/dist/src/streaming.js +907 -0
  27. package/dist/src/tools/remind.js +11 -10
  28. package/dist/src/types.d.ts +101 -0
  29. package/dist/src/types.js +17 -1
  30. package/dist/src/update-checker.js +2 -8
  31. package/dist/src/utils/audio-convert.d.ts +9 -0
  32. package/dist/src/utils/audio-convert.js +51 -0
  33. package/dist/src/utils/chunked-upload.d.ts +59 -0
  34. package/dist/src/utils/chunked-upload.js +289 -0
  35. package/dist/src/utils/file-utils.d.ts +7 -1
  36. package/dist/src/utils/file-utils.js +24 -2
  37. package/dist/src/utils/media-send.d.ts +147 -0
  38. package/dist/src/utils/media-send.js +434 -0
  39. package/dist/src/utils/pkg-version.d.ts +5 -0
  40. package/dist/src/utils/pkg-version.js +51 -0
  41. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  42. package/dist/src/utils/ssrf-guard.js +91 -0
  43. package/node_modules/ws/index.js +15 -6
  44. package/node_modules/ws/lib/permessage-deflate.js +6 -6
  45. package/node_modules/ws/lib/websocket-server.js +5 -5
  46. package/node_modules/ws/lib/websocket.js +6 -6
  47. package/node_modules/ws/package.json +4 -3
  48. package/node_modules/ws/wrapper.mjs +14 -1
  49. package/openclaw.plugin.json +1 -0
  50. package/package.json +11 -22
  51. package/scripts/postinstall-link-sdk.js +113 -0
  52. package/scripts/upgrade-via-npm.ps1 +161 -6
  53. package/scripts/upgrade-via-npm.sh +311 -104
  54. package/scripts/upgrade-via-source.sh +117 -0
  55. package/skills/qqbot-media/SKILL.md +9 -5
  56. package/skills/qqbot-remind/SKILL.md +3 -3
  57. package/src/admin-resolver.ts +76 -35
  58. package/src/api.ts +284 -12
  59. package/src/channel.ts +12 -0
  60. package/src/config.ts +3 -10
  61. package/src/deliver-debounce.ts +229 -0
  62. package/src/gateway.ts +277 -67
  63. package/src/image-server.ts +213 -77
  64. package/src/inbound-attachments.ts +32 -15
  65. package/src/outbound-deliver.ts +77 -157
  66. package/src/outbound.ts +304 -451
  67. package/src/reply-dispatcher.ts +4 -4
  68. package/src/request-context.ts +39 -0
  69. package/src/slash-commands.ts +303 -33
  70. package/src/startup-greeting.ts +35 -13
  71. package/src/streaming.ts +1096 -0
  72. package/src/tools/remind.ts +15 -11
  73. package/src/types.ts +111 -0
  74. package/src/update-checker.ts +2 -7
  75. package/src/utils/audio-convert.ts +56 -0
  76. package/src/utils/chunked-upload.ts +419 -0
  77. package/src/utils/file-utils.ts +28 -2
  78. package/src/utils/media-send.ts +563 -0
  79. package/src/utils/pkg-version.ts +54 -0
  80. package/src/utils/ssrf-guard.ts +102 -0
  81. package/clawdbot.plugin.json +0 -16
  82. package/dist/src/user-messages.d.ts +0 -8
  83. package/dist/src/user-messages.js +0 -8
  84. package/moltbot.plugin.json +0 -16
  85. package/scripts/upgrade-via-alt-pkg.sh +0 -307
  86. package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
  87. package/src/gateway.log +0 -43
  88. package/src/openclaw-2026-03-21.log +0 -3729
  89. package/src/user-messages.ts +0 -7
@@ -18,6 +18,9 @@ import { sendStartupGreetings } from "./admin-resolver.js";
18
18
  import { sendWithTokenRetry, sendErrorToTarget, handleStructuredPayload } from "./reply-dispatcher.js";
19
19
  import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
20
20
  import { parseAndSendMediaTags, sendPlainReply } from "./outbound-deliver.js";
21
+ import { createDeliverDebouncer } from "./deliver-debounce.js";
22
+ import { runWithRequestContext } from "./request-context.js";
23
+ import { StreamingController, shouldUseStreaming } from "./streaming.js";
21
24
  // QQ Bot intents - 按权限级别分组
22
25
  const INTENTS = {
23
26
  // 基础权限(默认有)
@@ -65,9 +68,10 @@ async function ensureImageServer(log, publicBaseUrl) {
65
68
  return null;
66
69
  }
67
70
  }
68
- // 模块级变量:进程生命周期内只有首次为 true
71
+ // 模块级变量:per-account 首次 READY 跟踪
69
72
  // 区分 gateway restart(进程重启)和 health-monitor 断线重连
70
- let isFirstReadyGlobal = true;
73
+ // 每个 account 首次 READY/RESUMED 时从 Set 中移除,之后不再发送问候语
74
+ const _pendingFirstReady = new Set();
71
75
  /**
72
76
  * 启动 Gateway WebSocket 连接(带自动重连)
73
77
  * 支持流式消息发送
@@ -84,6 +88,22 @@ export async function startGateway(ctx) {
84
88
  log?.info(`[qqbot:${account.accountId}] ${w}`);
85
89
  }
86
90
  }
91
+ // 预检 openclaw runtime 模块是否可正常解析(兼容性诊断)
92
+ // openclaw 3.23+ 存在 plugin-sdk/root-alias.cjs 回归 bug,
93
+ // 内置插件(qwen-portal-auth 等)全部加载失败,导致 AI agent 调用返回
94
+ // "Unable to resolve plugin runtime module"。提前检测并告警。
95
+ try {
96
+ const pluginRuntime = getQQBotRuntime();
97
+ if (pluginRuntime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
98
+ log?.info(`[qqbot:${account.accountId}] Runtime module preflight: OK`);
99
+ }
100
+ else {
101
+ log?.error(`[qqbot:${account.accountId}] ⚠️ Runtime preflight: dispatchReply API 不可用,AI 消息处理可能失败。请检查 openclaw 版本兼容性`);
102
+ }
103
+ }
104
+ catch (preflightErr) {
105
+ log?.error(`[qqbot:${account.accountId}] ⚠️ Runtime preflight failed: ${preflightErr}. AI 消息处理可能失败`);
106
+ }
87
107
  // 后台版本检查(供 /bot-version、/bot-upgrade 指令被动查询)
88
108
  triggerUpdateCheck(log);
89
109
  // 初始化 API 配置(markdown 支持)
@@ -158,8 +178,8 @@ export async function startGateway(ctx) {
158
178
  let isConnecting = false; // 防止并发连接
159
179
  let reconnectTimer = null; // 重连定时器
160
180
  let shouldRefreshToken = false; // 下次连接是否需要刷新 token
161
- // 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
162
- // health-monitor 重连不会重新初始化为 true
181
+ // 标记此 account 为待发问候(进程重启时 Set 里已有,断线重连不会重新加入)
182
+ _pendingFirstReady.add(account.accountId);
163
183
  const adminCtx = { accountId: account.accountId, appId: account.appId, clientSecret: account.clientSecret, log };
164
184
  // ============ P1-2: 尝试从持久化存储恢复 Session ============
165
185
  // 传入当前 appId,如果 appId 已变更(换了机器人),旧 session 自动失效
@@ -416,7 +436,7 @@ export async function startGateway(ctx) {
416
436
  systemPrompts.push(account.systemPrompt);
417
437
  }
418
438
  // 处理附件(图片等)- 下载到本地供 openclaw 访问
419
- const processed = await processAttachments(event.attachments, { accountId: account.accountId, cfg, log });
439
+ const processed = await processAttachments(event.attachments, { appId: account.appId, peerId, cfg, log });
420
440
  const { attachmentInfo, imageUrls, imageMediaTypes, voiceAttachmentPaths, voiceAttachmentUrls, voiceAsrReferTexts, voiceTranscripts, voiceTranscriptSources, attachmentLocalPaths } = processed;
421
441
  // 语音转录文本注入到用户消息中
422
442
  const voiceText = formatVoiceText(voiceTranscripts);
@@ -525,24 +545,24 @@ export async function startGateway(ctx) {
525
545
  }
526
546
  // ============ 构建 contextInfo(静态/动态分离) ============
527
547
  // 设计原则(参考 Telegram/Discord 做法):
528
- // - 静态指引:每条消息不变的内容(场景锚定、投递地址、能力说明),
548
+ // - 静态指引:每条消息不变的能力声明,
529
549
  // 注入 systemPrompts 前部,session 中虽重复出现但 AI 会自动降权,
530
550
  // 且保证长 session 窗口截断后仍可见。
531
551
  // - 动态标签:每条消息变化的数据(时间、附件、ASR),
532
552
  // 以紧凑的 [ctx] 块标注在用户消息前,最小化 token 开销。
533
553
  // --- 静态指引(仅注入框架信封未覆盖的 QQBot 特有信息) ---
534
554
  // 框架 formatInboundEnvelope 已提供:平台标识、发送者、时间戳
535
- // 这里只补充 QQBot 独有的:投递地址(cron skill 需要)
536
- const staticParts = [
537
- `[QQBot] to=${qualifiedTarget}`,
538
- ];
555
+ // 投递地址通过 AsyncLocalStorage 请求上下文传递给 remind 工具,无需在 agentBody 中暴露
556
+ const staticParts = [];
539
557
  // TTS 能力声明:仅在启用时告知 AI 可以发语音(媒体标签用法由 qqbot-media SKILL.md 提供)
540
558
  // STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
541
559
  if (hasTTS)
542
560
  staticParts.push("语音合成已启用");
543
- const staticInstruction = staticParts.join(" | ");
544
- // 静态指引作为 systemPrompts 的首项注入
545
- systemPrompts.unshift(staticInstruction);
561
+ // 仅在有静态指引时注入 systemPrompts
562
+ if (staticParts.length > 0) {
563
+ const staticInstruction = staticParts.join(" | ");
564
+ systemPrompts.unshift(staticInstruction);
565
+ }
546
566
  // --- 动态上下文(仅框架信封未覆盖的附件信息) ---
547
567
  const dynLines = [];
548
568
  if (imageUrls.length > 0) {
@@ -647,267 +667,449 @@ export async function startGateway(ctx) {
647
667
  const sendWithRetry = (sendFn) => sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId);
648
668
  // 发送错误提示的辅助函数
649
669
  const sendErrorMessage = (errorText) => sendErrorToTarget(replyCtx, errorText);
650
- try {
651
- const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
652
- // 追踪是否有响应
653
- let hasResponse = false;
654
- let hasBlockResponse = false; // 是否收到了面向用户的 block 回复
655
- let toolDeliverCount = 0; // tool deliver 计数
656
- const toolTexts = []; // 收集所有 tool deliver 文本
657
- const toolMediaUrls = []; // 收集所有 tool deliver 媒体 URL
658
- let toolFallbackSent = false; // 兜底消息是否已发送(只发一次)
659
- const responseTimeout = 120000; // 120秒超时(2分钟,与 TTS/文件生成超时对齐)
660
- const toolOnlyTimeout = 60000; // tool-only 兜底超时:60秒内没有 block 就兜底
661
- const maxToolRenewals = 3; // tool 续期上限:最多续期 3 次(总等待 = 60s × 3 = 180s)
662
- let toolRenewalCount = 0; // 已续期次数
663
- let timeoutId = null;
664
- let toolOnlyTimeoutId = null;
665
- // tool-only 兜底:转发工具产生的实际内容(媒体/文本),而非生硬的提示语
666
- const sendToolFallback = async () => {
667
- // 优先发送工具产出的媒体文件(TTS 语音、生成图片等)
668
- if (toolMediaUrls.length > 0) {
669
- log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding ${toolMediaUrls.length} media URL(s) from tool deliver(s)`);
670
- const mediaTimeout = 45000; // 单个媒体发送超时 45s
671
- for (const mediaUrl of toolMediaUrls) {
672
- try {
673
- const result = await Promise.race([
674
- sendMediaAuto({
675
- to: qualifiedTarget,
676
- text: "",
677
- mediaUrl,
678
- accountId: account.accountId,
679
- replyToId: event.messageId,
680
- account,
681
- }),
682
- new Promise((resolve) => setTimeout(() => resolve({ channel: "qqbot", error: `Tool fallback media send timeout (${mediaTimeout / 1000}s)` }), mediaTimeout)),
683
- ]);
684
- if (result.error) {
685
- log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia error: ${result.error}`);
670
+ // 使用 AsyncLocalStorage 建立请求级上下文,作用域内所有异步代码
671
+ // (包括 AI agent 调用、tool execute)都能安全获取当前会话信息,无并发竞态。
672
+ await runWithRequestContext({ target: qualifiedTarget }, async () => {
673
+ try {
674
+ const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
675
+ // 追踪是否有响应
676
+ let hasResponse = false;
677
+ let hasBlockResponse = false; // 是否收到了面向用户的 block 回复
678
+ let toolDeliverCount = 0; // tool deliver 计数
679
+ const toolTexts = []; // 收集所有 tool deliver 文本
680
+ const toolMediaUrls = []; // 收集所有 tool deliver 媒体 URL
681
+ let toolFallbackSent = false; // 兜底消息是否已发送(只发一次)
682
+ const blockDeliveredMediaUrls = new Set(); // block deliver 已处理的 mediaUrl,用于 tool 后到时去重
683
+ const responseTimeout = 120000; // 120秒超时(2分钟,与 TTS/文件生成超时对齐)
684
+ const toolOnlyTimeout = 60000; // tool-only 兜底超时:60秒内没有 block 就兜底
685
+ const maxToolRenewals = 3; // tool 续期上限:最多续期 3 次(总等待 = 60s × 3 = 180s)
686
+ let toolRenewalCount = 0; // 已续期次数
687
+ let timeoutId = null;
688
+ let toolOnlyTimeoutId = null;
689
+ // ============ Deliver Debouncer:合并短时间内连续到达的 block deliver ============
690
+ const debounceConfig = account.config?.deliverDebounce;
691
+ let debouncer = null;
692
+ // tool-only 兜底:转发工具产生的实际内容(媒体/文本),而非生硬的提示语
693
+ const sendToolFallback = async () => {
694
+ // 优先发送工具产出的媒体文件(TTS 语音、生成图片等)
695
+ if (toolMediaUrls.length > 0) {
696
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding ${toolMediaUrls.length} media URL(s) from tool deliver(s)`);
697
+ const mediaTimeout = 45000; // 单个媒体发送超时 45s
698
+ for (const mediaUrl of toolMediaUrls) {
699
+ try {
700
+ const result = await Promise.race([
701
+ sendMediaAuto({
702
+ to: qualifiedTarget,
703
+ text: "",
704
+ mediaUrl,
705
+ accountId: account.accountId,
706
+ replyToId: event.messageId,
707
+ account,
708
+ }),
709
+ new Promise((resolve) => setTimeout(() => resolve({ channel: "qqbot", error: `Tool fallback media send timeout (${mediaTimeout / 1000}s)` }), mediaTimeout)),
710
+ ]);
711
+ if (result.error) {
712
+ log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia error: ${result.error}`);
713
+ }
714
+ }
715
+ catch (err) {
716
+ log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${err}`);
686
717
  }
687
718
  }
688
- catch (err) {
689
- log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${err}`);
690
- }
719
+ return;
691
720
  }
692
- return;
693
- }
694
- // 其次转发工具产出的文本
695
- if (toolTexts.length > 0) {
696
- const text = toolTexts.slice(-3).join("\n---\n").slice(0, 2000);
697
- log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding tool text (${text.length} chars)`);
698
- await sendErrorMessage(text);
699
- return;
700
- }
701
- // 既无媒体也无文本,静默处理(仅日志记录)
702
- log?.info(`[qqbot:${account.accountId}] Tool fallback: no media or text collected from ${toolDeliverCount} tool deliver(s), silently dropping`);
703
- };
704
- const timeoutPromise = new Promise((_, reject) => {
705
- timeoutId = setTimeout(() => {
706
- if (!hasResponse) {
707
- reject(new Error("Response timeout"));
721
+ // 其次转发工具产出的文本
722
+ if (toolTexts.length > 0) {
723
+ const text = toolTexts.slice(-3).join("\n---\n").slice(0, 2000);
724
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding tool text (${text.length} chars)`);
725
+ await sendErrorMessage(text);
726
+ return;
708
727
  }
709
- }, responseTimeout);
710
- });
711
- const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
712
- ctx: ctxPayload,
713
- cfg,
714
- dispatcherOptions: {
715
- responsePrefix: messagesConfig.responsePrefix,
716
- deliver: async (payload, info) => {
717
- hasResponse = true;
718
- log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`);
719
- // ============ 跳过工具调用的中间结果(带兜底保护) ============
720
- if (info.kind === "tool") {
721
- toolDeliverCount++;
722
- const toolText = (payload.text ?? "").trim();
723
- if (toolText) {
724
- toolTexts.push(toolText);
725
- }
726
- // 收集工具产出的媒体 URL(TTS 语音、生成图片等),供 fallback 转发
727
- if (payload.mediaUrls?.length) {
728
- toolMediaUrls.push(...payload.mediaUrls);
729
- }
730
- if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
731
- toolMediaUrls.push(payload.mediaUrl);
732
- }
733
- log?.info(`[qqbot:${account.accountId}] Collected tool deliver #${toolDeliverCount}: text=${toolText.length} chars, media=${toolMediaUrls.length} URLs`);
734
- // block 已先发送完毕,tool 后到的媒体立即转发(典型场景:AI 先流式输出文本再执行 TTS)
735
- if (hasBlockResponse && toolMediaUrls.length > 0) {
736
- log?.info(`[qqbot:${account.accountId}] Block already sent, immediately forwarding ${toolMediaUrls.length} tool media URL(s)`);
737
- const urlsToSend = [...toolMediaUrls];
738
- toolMediaUrls.length = 0;
739
- for (const mediaUrl of urlsToSend) {
740
- try {
741
- const result = await sendMediaAuto({
742
- to: qualifiedTarget,
743
- text: "",
744
- mediaUrl,
745
- accountId: account.accountId,
746
- replyToId: event.messageId,
747
- account,
748
- });
749
- if (result.error) {
750
- log?.error(`[qqbot:${account.accountId}] Tool media immediate forward error: ${result.error}`);
728
+ // 既无媒体也无文本,静默处理(仅日志记录)
729
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: no media or text collected from ${toolDeliverCount} tool deliver(s), silently dropping`);
730
+ };
731
+ const timeoutPromise = new Promise((_, reject) => {
732
+ timeoutId = setTimeout(() => {
733
+ if (!hasResponse) {
734
+ reject(new Error("Response timeout"));
735
+ }
736
+ }, responseTimeout);
737
+ });
738
+ // ============ 流式消息控制器 ============
739
+ const targetType = event.type === "c2c" ? "c2c"
740
+ : event.type === "group" ? "group"
741
+ : "channel";
742
+ const useStreaming = shouldUseStreaming(account, targetType);
743
+ log?.info(`[qqbot:${account.accountId}] Streaming ${useStreaming ? "enabled" : "disabled"} for ${targetType} message from ${event.senderId}`);
744
+ let streamingController = null;
745
+ /** 创建一个新的 StreamingController 实例(用于初始创建和回复边界时重建) */
746
+ const createStreamingController = () => {
747
+ const ctrl = new StreamingController({
748
+ account,
749
+ userId: event.senderId,
750
+ replyToMsgId: event.messageId,
751
+ eventId: event.messageId,
752
+ logPrefix: `[qqbot:${account.accountId}:streaming]`,
753
+ log,
754
+ mediaContext: {
755
+ account,
756
+ event: {
757
+ type: event.type,
758
+ senderId: event.senderId,
759
+ messageId: event.messageId,
760
+ groupOpenid: event.groupOpenid,
761
+ channelId: event.channelId,
762
+ },
763
+ log,
764
+ },
765
+ // 回复边界回调:终结旧 controller 后创建新的,用新回复文本继续流式
766
+ onReplyBoundary: async (newReplyText) => {
767
+ log?.info(`[qqbot:${account.accountId}] Reply boundary: creating new StreamingController for new reply`);
768
+ const newCtrl = createStreamingController();
769
+ streamingController = newCtrl;
770
+ // 将新回复的初始文本交给新 controller 处理
771
+ await newCtrl.onPartialReply({ text: newReplyText });
772
+ },
773
+ });
774
+ return ctrl;
775
+ };
776
+ if (useStreaming) {
777
+ log?.info(`[qqbot:${account.accountId}] Streaming mode enabled for ${targetType} target`);
778
+ streamingController = createStreamingController();
779
+ }
780
+ const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
781
+ ctx: ctxPayload,
782
+ cfg,
783
+ dispatcherOptions: {
784
+ responsePrefix: messagesConfig.responsePrefix,
785
+ deliver: async (payload, info) => {
786
+ hasResponse = true;
787
+ log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`);
788
+ // ============ 跳过工具调用的中间结果(带兜底保护) ============
789
+ if (info.kind === "tool") {
790
+ toolDeliverCount++;
791
+ const toolText = (payload.text ?? "").trim();
792
+ if (toolText) {
793
+ toolTexts.push(toolText);
794
+ }
795
+ // 收集工具产出的媒体 URL(TTS 语音、生成图片等),供 fallback 转发
796
+ if (payload.mediaUrls?.length) {
797
+ toolMediaUrls.push(...payload.mediaUrls);
798
+ }
799
+ if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
800
+ toolMediaUrls.push(payload.mediaUrl);
801
+ }
802
+ log?.info(`[qqbot:${account.accountId}] Collected tool deliver #${toolDeliverCount}: text=${toolText.length} chars, media=${toolMediaUrls.length} URLs`);
803
+ // block 已先发送完毕,tool 后到的媒体立即转发(典型场景:AI 先流式输出文本再执行 TTS)
804
+ if (hasBlockResponse && toolMediaUrls.length > 0) {
805
+ // 去重:跳过已被 block deliver 的 sendPlainReply 处理过的 URL
806
+ const urlsToSend = toolMediaUrls.filter(url => !blockDeliveredMediaUrls.has(url));
807
+ const skippedCount = toolMediaUrls.length - urlsToSend.length;
808
+ toolMediaUrls.length = 0;
809
+ if (urlsToSend.length === 0) {
810
+ log?.info(`[qqbot:${account.accountId}] All ${skippedCount} tool media URL(s) already handled by block deliver, skipping`);
811
+ return;
812
+ }
813
+ log?.info(`[qqbot:${account.accountId}] Block already sent, immediately forwarding ${urlsToSend.length} tool media URL(s) (deduped from block deliver)`);
814
+ for (const mediaUrl of urlsToSend) {
815
+ try {
816
+ const result = await sendMediaAuto({
817
+ to: qualifiedTarget,
818
+ text: "",
819
+ mediaUrl,
820
+ accountId: account.accountId,
821
+ replyToId: event.messageId,
822
+ account,
823
+ });
824
+ if (result.error) {
825
+ log?.error(`[qqbot:${account.accountId}] Tool media immediate forward error: ${result.error}`);
826
+ }
827
+ else {
828
+ log?.info(`[qqbot:${account.accountId}] Forwarded tool media (post-block): ${mediaUrl.slice(0, 80)}...`);
829
+ }
751
830
  }
752
- else {
753
- log?.info(`[qqbot:${account.accountId}] Forwarded tool media (post-block): ${mediaUrl.slice(0, 80)}...`);
831
+ catch (err) {
832
+ log?.error(`[qqbot:${account.accountId}] Tool media immediate forward failed: ${err}`);
754
833
  }
755
834
  }
756
- catch (err) {
757
- log?.error(`[qqbot:${account.accountId}] Tool media immediate forward failed: ${err}`);
835
+ return;
836
+ }
837
+ // 兜底已发送,不再续期
838
+ if (toolFallbackSent) {
839
+ return;
840
+ }
841
+ // tool-only 超时保护:收到 tool 但迟迟没有 block 时,启动兜底定时器
842
+ // 续期有上限(maxToolRenewals 次),防止无限工具调用永远不触发兜底
843
+ if (toolOnlyTimeoutId) {
844
+ if (toolRenewalCount < maxToolRenewals) {
845
+ clearTimeout(toolOnlyTimeoutId);
846
+ toolRenewalCount++;
847
+ log?.info(`[qqbot:${account.accountId}] Tool-only timer renewed (${toolRenewalCount}/${maxToolRenewals})`);
848
+ }
849
+ else {
850
+ // 已达续期上限,不再重置,等定时器自然触发兜底
851
+ log?.info(`[qqbot:${account.accountId}] Tool-only timer renewal limit reached (${maxToolRenewals}), waiting for timeout`);
852
+ return;
758
853
  }
759
854
  }
855
+ toolOnlyTimeoutId = setTimeout(async () => {
856
+ if (!hasBlockResponse && !toolFallbackSent) {
857
+ toolFallbackSent = true;
858
+ log?.error(`[qqbot:${account.accountId}] Tool-only timeout: ${toolDeliverCount} tool deliver(s) but no block within ${toolOnlyTimeout / 1000}s, sending fallback`);
859
+ try {
860
+ await sendToolFallback();
861
+ }
862
+ catch (sendErr) {
863
+ log?.error(`[qqbot:${account.accountId}] Failed to send tool-only fallback: ${sendErr}`);
864
+ }
865
+ }
866
+ }, toolOnlyTimeout);
760
867
  return;
761
868
  }
762
- // 兜底已发送,不再续期
763
- if (toolFallbackSent) {
764
- return;
869
+ // 收到 block 回复,清除所有超时定时器
870
+ hasBlockResponse = true;
871
+ // 收到真正回复,立即停止输入状态续期(让 "输入中" 尽快消失)
872
+ typing.keepAlive?.stop();
873
+ if (timeoutId) {
874
+ clearTimeout(timeoutId);
875
+ timeoutId = null;
765
876
  }
766
- // tool-only 超时保护:收到 tool 但迟迟没有 block 时,启动兜底定时器
767
- // 续期有上限(maxToolRenewals 次),防止无限工具调用永远不触发兜底
768
877
  if (toolOnlyTimeoutId) {
769
- if (toolRenewalCount < maxToolRenewals) {
770
- clearTimeout(toolOnlyTimeoutId);
771
- toolRenewalCount++;
772
- log?.info(`[qqbot:${account.accountId}] Tool-only timer renewed (${toolRenewalCount}/${maxToolRenewals})`);
878
+ clearTimeout(toolOnlyTimeoutId);
879
+ toolOnlyTimeoutId = null;
880
+ }
881
+ if (toolDeliverCount > 0) {
882
+ log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
883
+ }
884
+ // ============ 流式模式处理 ============
885
+ // 流式模式下,所有 block deliver 内容(含媒体标签)统一交由 StreamingController 处理。
886
+ // StreamingController 内部有重试机制;如果一个分片都没发出去则降级到普通消息。
887
+ if (streamingController && !streamingController.isTerminalPhase) {
888
+ const deliverTextLen = (payload.text ?? "").length;
889
+ const deliverPreview = (payload.text ?? "").slice(0, 40).replace(/\n/g, "\\n");
890
+ log?.debug?.(`[qqbot:${account.accountId}] Streaming deliver entry, textLen=${deliverTextLen}, phase=${streamingController.currentPhase}, sentChunks=${streamingController.sentChunkCount_debug}, preview="${deliverPreview}"`);
891
+ try {
892
+ await streamingController.onDeliver(payload);
893
+ log?.debug?.(`[qqbot:${account.accountId}] Streaming deliver done, phase=${streamingController.currentPhase}`);
894
+ }
895
+ catch (err) {
896
+ // StreamingController 内部已有重试,这里只打日志
897
+ log?.error(`[qqbot:${account.accountId}] Streaming deliver error: ${err}`);
898
+ }
899
+ // 检查是否因流式 API 不可用而需要降级(ensureStreamingStarted 全部失败)
900
+ // 如果需要降级,不 return,让本次 deliver 的 payload.text(全量文本)继续走普通发送逻辑
901
+ if (streamingController.shouldFallbackToStatic) {
902
+ log?.info(`[qqbot:${account.accountId}] Streaming API unavailable, falling back to static for this deliver`);
903
+ // 不 return,继续走普通发送逻辑(payload.text 是完整文本)
773
904
  }
774
905
  else {
775
- // 已达续期上限,不再重置,等定时器自然触发兜底
776
- log?.info(`[qqbot:${account.accountId}] Tool-only timer renewal limit reached (${maxToolRenewals}), waiting for timeout`);
906
+ // 流式正常处理,不走普通发送逻辑
907
+ pluginRuntime.channel.activity.record({
908
+ channel: "qqbot",
909
+ accountId: account.accountId,
910
+ direction: "outbound",
911
+ });
777
912
  return;
778
913
  }
779
914
  }
780
- toolOnlyTimeoutId = setTimeout(async () => {
781
- if (!hasBlockResponse && !toolFallbackSent) {
782
- toolFallbackSent = true;
783
- log?.error(`[qqbot:${account.accountId}] Tool-only timeout: ${toolDeliverCount} tool deliver(s) but no block within ${toolOnlyTimeout / 1000}s, sending fallback`);
784
- try {
785
- await sendToolFallback();
786
- }
787
- catch (sendErr) {
788
- log?.error(`[qqbot:${account.accountId}] Failed to send tool-only fallback: ${sendErr}`);
915
+ // ============ 实际发送逻辑(可被 debouncer 包裹) ============
916
+ const executeDeliver = async (deliverPayload, _deliverInfo) => {
917
+ // ============ 引用回复 ============
918
+ const quoteRef = event.msgIdx;
919
+ let quoteRefUsed = false;
920
+ const consumeQuoteRef = () => {
921
+ if (quoteRef && !quoteRefUsed) {
922
+ quoteRefUsed = true;
923
+ return quoteRef;
789
924
  }
925
+ return undefined;
926
+ };
927
+ let replyText = deliverPayload.text ?? "";
928
+ // ============ 媒体标签解析 + 发送 ============
929
+ const deliverEvent = {
930
+ type: event.type,
931
+ senderId: event.senderId,
932
+ messageId: event.messageId,
933
+ channelId: event.channelId,
934
+ groupOpenid: event.groupOpenid,
935
+ msgIdx: event.msgIdx,
936
+ };
937
+ const deliverActx = { account, qualifiedTarget, log };
938
+ const mediaResult = await parseAndSendMediaTags(replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef);
939
+ if (mediaResult.handled) {
940
+ pluginRuntime.channel.activity.record({
941
+ channel: "qqbot",
942
+ accountId: account.accountId,
943
+ direction: "outbound",
944
+ });
945
+ return;
790
946
  }
791
- }, toolOnlyTimeout);
792
- return;
793
- }
794
- // 收到 block 回复,清除所有超时定时器
795
- hasBlockResponse = true;
796
- // 收到真正回复,立即停止输入状态续期(让 "输入中" 尽快消失)
797
- typing.keepAlive?.stop();
798
- if (timeoutId) {
799
- clearTimeout(timeoutId);
800
- timeoutId = null;
801
- }
802
- if (toolOnlyTimeoutId) {
803
- clearTimeout(toolOnlyTimeoutId);
804
- toolOnlyTimeoutId = null;
805
- }
806
- if (toolDeliverCount > 0) {
807
- log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
808
- }
809
- // ============ 引用回复 ============
810
- const quoteRef = event.msgIdx;
811
- let quoteRefUsed = false;
812
- const consumeQuoteRef = () => {
813
- if (quoteRef && !quoteRefUsed) {
814
- quoteRefUsed = true;
815
- return quoteRef;
947
+ replyText = mediaResult.normalizedText;
948
+ // ============ 结构化载荷检测与分发 ============
949
+ const recordOutboundActivity = () => pluginRuntime.channel.activity.record({
950
+ channel: "qqbot",
951
+ accountId: account.accountId,
952
+ direction: "outbound",
953
+ });
954
+ const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
955
+ if (handled)
956
+ return;
957
+ // ============ 非结构化消息发送 ============
958
+ // 记录 block deliver 处理的 mediaUrl,供 tool 后到时去重
959
+ if (deliverPayload.mediaUrl)
960
+ blockDeliveredMediaUrls.add(deliverPayload.mediaUrl);
961
+ if (deliverPayload.mediaUrls)
962
+ for (const u of deliverPayload.mediaUrls)
963
+ blockDeliveredMediaUrls.add(u);
964
+ await sendPlainReply(deliverPayload, replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef, toolMediaUrls);
965
+ pluginRuntime.channel.activity.record({
966
+ channel: "qqbot",
967
+ accountId: account.accountId,
968
+ direction: "outbound",
969
+ });
970
+ };
971
+ // ============ Debounce 合并回复 ============
972
+ if (!debouncer) {
973
+ debouncer = createDeliverDebouncer(debounceConfig, executeDeliver, log, `[qqbot:${account.accountId}:debounce]`);
816
974
  }
817
- return undefined;
818
- };
819
- let replyText = payload.text ?? "";
820
- // ============ 媒体标签解析 + 发送 ============
821
- const deliverEvent = {
822
- type: event.type,
823
- senderId: event.senderId,
824
- messageId: event.messageId,
825
- channelId: event.channelId,
826
- groupOpenid: event.groupOpenid,
827
- msgIdx: event.msgIdx,
828
- };
829
- const deliverActx = { account, qualifiedTarget, log };
830
- const mediaResult = await parseAndSendMediaTags(replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef);
831
- if (mediaResult.handled) {
832
- pluginRuntime.channel.activity.record({
833
- channel: "qqbot",
834
- accountId: account.accountId,
835
- direction: "outbound",
836
- });
837
- return;
838
- }
839
- replyText = mediaResult.normalizedText;
840
- // ============ 结构化载荷检测与分发 ============
841
- const recordOutboundActivity = () => pluginRuntime.channel.activity.record({
842
- channel: "qqbot",
843
- accountId: account.accountId,
844
- direction: "outbound",
845
- });
846
- const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
847
- if (handled)
848
- return;
849
- // ============ 非结构化消息发送 ============
850
- await sendPlainReply(payload, replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef, toolMediaUrls);
851
- pluginRuntime.channel.activity.record({
852
- channel: "qqbot",
853
- accountId: account.accountId,
854
- direction: "outbound",
855
- });
975
+ if (debouncer) {
976
+ await debouncer.deliver(payload, info);
977
+ }
978
+ else {
979
+ await executeDeliver(payload, info);
980
+ }
981
+ },
982
+ onError: async (err) => {
983
+ log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
984
+ hasResponse = true;
985
+ if (timeoutId) {
986
+ clearTimeout(timeoutId);
987
+ timeoutId = null;
988
+ }
989
+ // 流式模式:委托给 streaming controller 处理错误
990
+ if (streamingController && !streamingController.isTerminalPhase) {
991
+ try {
992
+ await streamingController.onError(err);
993
+ }
994
+ catch (streamErr) {
995
+ log?.error(`[qqbot:${account.accountId}] Streaming onError failed: ${streamErr}`);
996
+ }
997
+ // 如果 onError 中因无分片发出而降级,不 return,走普通错误处理
998
+ if (streamingController.shouldFallbackToStatic) {
999
+ log?.info(`[qqbot:${account.accountId}] Streaming onError: no chunk sent, falling back to static error handling`);
1000
+ // 不 return,继续走普通错误处理
1001
+ }
1002
+ else {
1003
+ return;
1004
+ }
1005
+ }
1006
+ const errMsg = String(err);
1007
+ // 兼容 openclaw 3.23+ 的 plugin-sdk/root-alias.cjs 模块解析失败
1008
+ if (errMsg.includes("Unable to resolve plugin runtime module") || errMsg.includes("root-alias.cjs")) {
1009
+ log?.error(`[qqbot:${account.accountId}] ⚠️ openclaw 框架 runtime 模块解析失败,可能是 openclaw 版本与 plugin-sdk 不兼容。请尝试: npm install -g openclaw@latest && openclaw gateway restart`);
1010
+ await sendErrorMessage("⚠️ AI 服务暂时不可用:openclaw 框架运行时模块加载失败。\n\n请管理员执行:\nnpm install -g openclaw@latest\nopenclaw gateway restart\n\n斜杠命令(如 /bot-ping)不受影响。");
1011
+ return;
1012
+ }
1013
+ if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
1014
+ log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
1015
+ }
1016
+ else {
1017
+ log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
1018
+ }
1019
+ },
856
1020
  },
857
- onError: async (err) => {
858
- log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
859
- hasResponse = true;
860
- if (timeoutId) {
861
- clearTimeout(timeoutId);
862
- timeoutId = null;
863
- }
864
- // 发送错误提示给用户,显示完整错误信息
865
- const errMsg = String(err);
866
- if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
867
- log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
1021
+ replyOptions: {
1022
+ // 流式模式时禁用 block streaming
1023
+ disableBlockStreaming: !useStreaming,
1024
+ // 流式模式下注册 onPartialReply 回调,接收流式文本增量
1025
+ ...(streamingController ? {
1026
+ onPartialReply: async (payload) => {
1027
+ const textLen = payload.text?.length ?? 0;
1028
+ const preview = (payload.text ?? "").slice(0, 40).replace(/\n/g, "\\n");
1029
+ log?.debug?.(`[qqbot:${account.accountId}] onPartialReply called, textLen=${textLen}, phase=${streamingController.currentPhase}, isTerminal=${streamingController.isTerminalPhase}, preview="${preview}"`);
1030
+ try {
1031
+ await streamingController.onPartialReply(payload);
1032
+ log?.debug?.(`[qqbot:${account.accountId}] onPartialReply done, phase=${streamingController.currentPhase}`);
1033
+ }
1034
+ catch (err) {
1035
+ // StreamingController 内部已有重试,这里只打日志
1036
+ log?.error(`[qqbot:${account.accountId}] Streaming onPartialReply error: ${err}`);
1037
+ }
1038
+ },
1039
+ } : {}),
1040
+ },
1041
+ });
1042
+ // 等待分发完成或超时
1043
+ try {
1044
+ await Promise.race([dispatchPromise, timeoutPromise]);
1045
+ }
1046
+ catch (err) {
1047
+ if (timeoutId) {
1048
+ clearTimeout(timeoutId);
1049
+ }
1050
+ if (!hasResponse) {
1051
+ log?.error(`[qqbot:${account.accountId}] No response within timeout`);
1052
+ }
1053
+ }
1054
+ finally {
1055
+ // 清理 tool-only 兜底定时器
1056
+ if (toolOnlyTimeoutId) {
1057
+ clearTimeout(toolOnlyTimeoutId);
1058
+ toolOnlyTimeoutId = null;
1059
+ }
1060
+ // dispatch 完成后,如果只有 tool 没有 block,且尚未发过兜底,立即兜底
1061
+ if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
1062
+ toolFallbackSent = true;
1063
+ log?.error(`[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`);
1064
+ await sendToolFallback();
1065
+ }
1066
+ // 销毁 debouncer,flush 剩余缓冲的文本
1067
+ if (debouncer) {
1068
+ await debouncer.dispose();
1069
+ debouncer = null;
1070
+ }
1071
+ // ============ 流式消息收尾 ============
1072
+ // dispatch 完成后,标记流式控制器已完成并触发 onIdle(发送终结分片)
1073
+ if (streamingController && !streamingController.isTerminalPhase) {
1074
+ try {
1075
+ streamingController.markFullyComplete();
1076
+ await streamingController.onIdle();
1077
+ log?.debug?.(`[qqbot:${account.accountId}] Streaming controller finalized`);
868
1078
  }
869
- else {
870
- log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
1079
+ catch (err) {
1080
+ log?.error(`[qqbot:${account.accountId}] Streaming finalization error: ${err}`);
1081
+ // 尝试中止
1082
+ try {
1083
+ await streamingController.abortStreaming();
1084
+ }
1085
+ catch { /* ignore */ }
871
1086
  }
872
- },
873
- },
874
- replyOptions: {
875
- disableBlockStreaming: true,
876
- },
877
- });
878
- // 等待分发完成或超时
879
- try {
880
- await Promise.race([dispatchPromise, timeoutPromise]);
1087
+ }
1088
+ // ============ 流式降级到非流式 ============
1089
+ // 无需额外处理:如果流式 API 不可用(shouldFallbackToStatic),
1090
+ // deliver 回调中已自动跳过流式拦截,走普通消息发送逻辑。
1091
+ // (每次 deliver 收到的都是全量文本,不需要在 controller 内部保存累积文本)
1092
+ if (streamingController?.shouldFallbackToStatic) {
1093
+ log?.debug?.(`[qqbot:${account.accountId}] Streaming was degraded to static mode (no chunk sent successfully)`);
1094
+ }
1095
+ }
881
1096
  }
882
1097
  catch (err) {
883
- if (timeoutId) {
884
- clearTimeout(timeoutId);
885
- }
886
- if (!hasResponse) {
887
- log?.error(`[qqbot:${account.accountId}] No response within timeout`);
1098
+ const errStr = String(err);
1099
+ log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
1100
+ // 兼容 openclaw 3.23+ runtime 模块解析失败:给用户发可操作的提示
1101
+ if (errStr.includes("Unable to resolve plugin runtime module") || errStr.includes("root-alias.cjs")) {
1102
+ try {
1103
+ await sendErrorMessage("⚠️ AI 服务暂时不可用:openclaw 框架运行时模块加载失败。\n\n请管理员执行:\nnpm install -g openclaw@latest\nopenclaw gateway restart\n\n斜杠命令(如 /bot-ping)不受影响。");
1104
+ }
1105
+ catch { /* best-effort */ }
888
1106
  }
889
1107
  }
890
1108
  finally {
891
- // 清理 tool-only 兜底定时器
892
- if (toolOnlyTimeoutId) {
893
- clearTimeout(toolOnlyTimeoutId);
894
- toolOnlyTimeoutId = null;
895
- }
896
- // dispatch 完成后,如果只有 tool 没有 block,且尚未发过兜底,立即兜底
897
- if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
898
- toolFallbackSent = true;
899
- log?.error(`[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`);
900
- await sendToolFallback();
901
- }
1109
+ // 无论成功/失败/超时,都停止输入状态续期
1110
+ typing.keepAlive?.stop();
902
1111
  }
903
- }
904
- catch (err) {
905
- log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
906
- }
907
- finally {
908
- // 无论成功/失败/超时,都停止输入状态续期
909
- typing.keepAlive?.stop();
910
- }
1112
+ }); // end runWithRequestContext
911
1113
  };
912
1114
  ws.on("open", () => {
913
1115
  log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
@@ -999,11 +1201,11 @@ export async function startGateway(ctx) {
999
1201
  onReady?.(d);
1000
1202
  // 仅 startGateway 后的首次 READY 才发送上线通知
1001
1203
  // ws 断线重连(resume 失败后重新 Identify)产生的 READY 不发送
1002
- if (!isFirstReadyGlobal) {
1204
+ if (!_pendingFirstReady.has(account.accountId)) {
1003
1205
  log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (reconnect READY, not first startup)`);
1004
1206
  }
1005
1207
  else {
1006
- isFirstReadyGlobal = false;
1208
+ _pendingFirstReady.delete(account.accountId);
1007
1209
  sendStartupGreetings(adminCtx, "READY");
1008
1210
  } // end isFirstReady
1009
1211
  }
@@ -1011,8 +1213,8 @@ export async function startGateway(ctx) {
1011
1213
  log?.info(`[qqbot:${account.accountId}] Session resumed`);
1012
1214
  onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
1013
1215
  // RESUMED 也属于首次启动(gateway restart 通常走 resume)
1014
- if (isFirstReadyGlobal) {
1015
- isFirstReadyGlobal = false;
1216
+ if (_pendingFirstReady.has(account.accountId)) {
1217
+ _pendingFirstReady.delete(account.accountId);
1016
1218
  sendStartupGreetings(adminCtx, "RESUMED");
1017
1219
  }
1018
1220
  // P1-2: 更新 Session 连接时间