@tencent-connect/openclaw-qqbot 1.6.4 → 1.6.5-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.
@@ -18,6 +18,8 @@ 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";
21
23
  // QQ Bot intents - 按权限级别分组
22
24
  const INTENTS = {
23
25
  // 基础权限(默认有)
@@ -65,9 +67,10 @@ async function ensureImageServer(log, publicBaseUrl) {
65
67
  return null;
66
68
  }
67
69
  }
68
- // 模块级变量:进程生命周期内只有首次为 true
70
+ // 模块级变量:per-account 首次 READY 跟踪
69
71
  // 区分 gateway restart(进程重启)和 health-monitor 断线重连
70
- let isFirstReadyGlobal = true;
72
+ // 每个 account 首次 READY/RESUMED 时从 Set 中移除,之后不再发送问候语
73
+ const _pendingFirstReady = new Set();
71
74
  /**
72
75
  * 启动 Gateway WebSocket 连接(带自动重连)
73
76
  * 支持流式消息发送
@@ -158,8 +161,8 @@ export async function startGateway(ctx) {
158
161
  let isConnecting = false; // 防止并发连接
159
162
  let reconnectTimer = null; // 重连定时器
160
163
  let shouldRefreshToken = false; // 下次连接是否需要刷新 token
161
- // 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
162
- // health-monitor 重连不会重新初始化为 true
164
+ // 标记此 account 为待发问候(进程重启时 Set 里已有,断线重连不会重新加入)
165
+ _pendingFirstReady.add(account.accountId);
163
166
  const adminCtx = { accountId: account.accountId, appId: account.appId, clientSecret: account.clientSecret, log };
164
167
  // ============ P1-2: 尝试从持久化存储恢复 Session ============
165
168
  // 传入当前 appId,如果 appId 已变更(换了机器人),旧 session 自动失效
@@ -525,24 +528,24 @@ export async function startGateway(ctx) {
525
528
  }
526
529
  // ============ 构建 contextInfo(静态/动态分离) ============
527
530
  // 设计原则(参考 Telegram/Discord 做法):
528
- // - 静态指引:每条消息不变的内容(场景锚定、投递地址、能力说明),
531
+ // - 静态指引:每条消息不变的能力声明,
529
532
  // 注入 systemPrompts 前部,session 中虽重复出现但 AI 会自动降权,
530
533
  // 且保证长 session 窗口截断后仍可见。
531
534
  // - 动态标签:每条消息变化的数据(时间、附件、ASR),
532
535
  // 以紧凑的 [ctx] 块标注在用户消息前,最小化 token 开销。
533
536
  // --- 静态指引(仅注入框架信封未覆盖的 QQBot 特有信息) ---
534
537
  // 框架 formatInboundEnvelope 已提供:平台标识、发送者、时间戳
535
- // 这里只补充 QQBot 独有的:投递地址(cron skill 需要)
536
- const staticParts = [
537
- `[QQBot] to=${qualifiedTarget}`,
538
- ];
538
+ // 投递地址通过 AsyncLocalStorage 请求上下文传递给 remind 工具,无需在 agentBody 中暴露
539
+ const staticParts = [];
539
540
  // TTS 能力声明:仅在启用时告知 AI 可以发语音(媒体标签用法由 qqbot-media SKILL.md 提供)
540
541
  // STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
541
542
  if (hasTTS)
542
543
  staticParts.push("语音合成已启用");
543
- const staticInstruction = staticParts.join(" | ");
544
- // 静态指引作为 systemPrompts 的首项注入
545
- systemPrompts.unshift(staticInstruction);
544
+ // 仅在有静态指引时注入 systemPrompts
545
+ if (staticParts.length > 0) {
546
+ const staticInstruction = staticParts.join(" | ");
547
+ systemPrompts.unshift(staticInstruction);
548
+ }
546
549
  // --- 动态上下文(仅框架信封未覆盖的附件信息) ---
547
550
  const dynLines = [];
548
551
  if (imageUrls.length > 0) {
@@ -647,267 +650,292 @@ export async function startGateway(ctx) {
647
650
  const sendWithRetry = (sendFn) => sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId);
648
651
  // 发送错误提示的辅助函数
649
652
  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}`);
653
+ // 使用 AsyncLocalStorage 建立请求级上下文,作用域内所有异步代码
654
+ // (包括 AI agent 调用、tool execute)都能安全获取当前会话信息,无并发竞态。
655
+ await runWithRequestContext({ target: qualifiedTarget }, async () => {
656
+ try {
657
+ const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
658
+ // 追踪是否有响应
659
+ let hasResponse = false;
660
+ let hasBlockResponse = false; // 是否收到了面向用户的 block 回复
661
+ let toolDeliverCount = 0; // tool deliver 计数
662
+ const toolTexts = []; // 收集所有 tool deliver 文本
663
+ const toolMediaUrls = []; // 收集所有 tool deliver 媒体 URL
664
+ let toolFallbackSent = false; // 兜底消息是否已发送(只发一次)
665
+ const responseTimeout = 120000; // 120秒超时(2分钟,与 TTS/文件生成超时对齐)
666
+ const toolOnlyTimeout = 60000; // tool-only 兜底超时:60秒内没有 block 就兜底
667
+ const maxToolRenewals = 3; // tool 续期上限:最多续期 3 次(总等待 = 60s × 3 = 180s)
668
+ let toolRenewalCount = 0; // 已续期次数
669
+ let timeoutId = null;
670
+ let toolOnlyTimeoutId = null;
671
+ // ============ Deliver Debouncer:合并短时间内连续到达的 block deliver ============
672
+ const debounceConfig = account.config?.deliverDebounce;
673
+ let debouncer = null;
674
+ // tool-only 兜底:转发工具产生的实际内容(媒体/文本),而非生硬的提示语
675
+ const sendToolFallback = async () => {
676
+ // 优先发送工具产出的媒体文件(TTS 语音、生成图片等)
677
+ if (toolMediaUrls.length > 0) {
678
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding ${toolMediaUrls.length} media URL(s) from tool deliver(s)`);
679
+ const mediaTimeout = 45000; // 单个媒体发送超时 45s
680
+ for (const mediaUrl of toolMediaUrls) {
681
+ try {
682
+ const result = await Promise.race([
683
+ sendMediaAuto({
684
+ to: qualifiedTarget,
685
+ text: "",
686
+ mediaUrl,
687
+ accountId: account.accountId,
688
+ replyToId: event.messageId,
689
+ account,
690
+ }),
691
+ new Promise((resolve) => setTimeout(() => resolve({ channel: "qqbot", error: `Tool fallback media send timeout (${mediaTimeout / 1000}s)` }), mediaTimeout)),
692
+ ]);
693
+ if (result.error) {
694
+ log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia error: ${result.error}`);
695
+ }
696
+ }
697
+ catch (err) {
698
+ log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${err}`);
686
699
  }
687
700
  }
688
- catch (err) {
689
- log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${err}`);
690
- }
701
+ return;
691
702
  }
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"));
703
+ // 其次转发工具产出的文本
704
+ if (toolTexts.length > 0) {
705
+ const text = toolTexts.slice(-3).join("\n---\n").slice(0, 2000);
706
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: forwarding tool text (${text.length} chars)`);
707
+ await sendErrorMessage(text);
708
+ return;
708
709
  }
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}`);
710
+ // 既无媒体也无文本,静默处理(仅日志记录)
711
+ log?.info(`[qqbot:${account.accountId}] Tool fallback: no media or text collected from ${toolDeliverCount} tool deliver(s), silently dropping`);
712
+ };
713
+ const timeoutPromise = new Promise((_, reject) => {
714
+ timeoutId = setTimeout(() => {
715
+ if (!hasResponse) {
716
+ reject(new Error("Response timeout"));
717
+ }
718
+ }, responseTimeout);
719
+ });
720
+ const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
721
+ ctx: ctxPayload,
722
+ cfg,
723
+ dispatcherOptions: {
724
+ responsePrefix: messagesConfig.responsePrefix,
725
+ deliver: async (payload, info) => {
726
+ hasResponse = true;
727
+ log?.info(`[qqbot:${account.accountId}] deliver called, kind: ${info.kind}, payload keys: ${Object.keys(payload).join(", ")}`);
728
+ // ============ 跳过工具调用的中间结果(带兜底保护) ============
729
+ if (info.kind === "tool") {
730
+ toolDeliverCount++;
731
+ const toolText = (payload.text ?? "").trim();
732
+ if (toolText) {
733
+ toolTexts.push(toolText);
734
+ }
735
+ // 收集工具产出的媒体 URL(TTS 语音、生成图片等),供 fallback 转发
736
+ if (payload.mediaUrls?.length) {
737
+ toolMediaUrls.push(...payload.mediaUrls);
738
+ }
739
+ if (payload.mediaUrl && !toolMediaUrls.includes(payload.mediaUrl)) {
740
+ toolMediaUrls.push(payload.mediaUrl);
741
+ }
742
+ log?.info(`[qqbot:${account.accountId}] Collected tool deliver #${toolDeliverCount}: text=${toolText.length} chars, media=${toolMediaUrls.length} URLs`);
743
+ // block 已先发送完毕,tool 后到的媒体立即转发(典型场景:AI 先流式输出文本再执行 TTS)
744
+ if (hasBlockResponse && toolMediaUrls.length > 0) {
745
+ log?.info(`[qqbot:${account.accountId}] Block already sent, immediately forwarding ${toolMediaUrls.length} tool media URL(s)`);
746
+ const urlsToSend = [...toolMediaUrls];
747
+ toolMediaUrls.length = 0;
748
+ for (const mediaUrl of urlsToSend) {
749
+ try {
750
+ const result = await sendMediaAuto({
751
+ to: qualifiedTarget,
752
+ text: "",
753
+ mediaUrl,
754
+ accountId: account.accountId,
755
+ replyToId: event.messageId,
756
+ account,
757
+ });
758
+ if (result.error) {
759
+ log?.error(`[qqbot:${account.accountId}] Tool media immediate forward error: ${result.error}`);
760
+ }
761
+ else {
762
+ log?.info(`[qqbot:${account.accountId}] Forwarded tool media (post-block): ${mediaUrl.slice(0, 80)}...`);
763
+ }
751
764
  }
752
- else {
753
- log?.info(`[qqbot:${account.accountId}] Forwarded tool media (post-block): ${mediaUrl.slice(0, 80)}...`);
765
+ catch (err) {
766
+ log?.error(`[qqbot:${account.accountId}] Tool media immediate forward failed: ${err}`);
754
767
  }
755
768
  }
756
- catch (err) {
757
- log?.error(`[qqbot:${account.accountId}] Tool media immediate forward failed: ${err}`);
769
+ return;
770
+ }
771
+ // 兜底已发送,不再续期
772
+ if (toolFallbackSent) {
773
+ return;
774
+ }
775
+ // tool-only 超时保护:收到 tool 但迟迟没有 block 时,启动兜底定时器
776
+ // 续期有上限(maxToolRenewals 次),防止无限工具调用永远不触发兜底
777
+ if (toolOnlyTimeoutId) {
778
+ if (toolRenewalCount < maxToolRenewals) {
779
+ clearTimeout(toolOnlyTimeoutId);
780
+ toolRenewalCount++;
781
+ log?.info(`[qqbot:${account.accountId}] Tool-only timer renewed (${toolRenewalCount}/${maxToolRenewals})`);
782
+ }
783
+ else {
784
+ // 已达续期上限,不再重置,等定时器自然触发兜底
785
+ log?.info(`[qqbot:${account.accountId}] Tool-only timer renewal limit reached (${maxToolRenewals}), waiting for timeout`);
786
+ return;
758
787
  }
759
788
  }
789
+ toolOnlyTimeoutId = setTimeout(async () => {
790
+ if (!hasBlockResponse && !toolFallbackSent) {
791
+ toolFallbackSent = true;
792
+ log?.error(`[qqbot:${account.accountId}] Tool-only timeout: ${toolDeliverCount} tool deliver(s) but no block within ${toolOnlyTimeout / 1000}s, sending fallback`);
793
+ try {
794
+ await sendToolFallback();
795
+ }
796
+ catch (sendErr) {
797
+ log?.error(`[qqbot:${account.accountId}] Failed to send tool-only fallback: ${sendErr}`);
798
+ }
799
+ }
800
+ }, toolOnlyTimeout);
760
801
  return;
761
802
  }
762
- // 兜底已发送,不再续期
763
- if (toolFallbackSent) {
764
- return;
803
+ // 收到 block 回复,清除所有超时定时器
804
+ hasBlockResponse = true;
805
+ // 收到真正回复,立即停止输入状态续期(让 "输入中" 尽快消失)
806
+ typing.keepAlive?.stop();
807
+ if (timeoutId) {
808
+ clearTimeout(timeoutId);
809
+ timeoutId = null;
765
810
  }
766
- // tool-only 超时保护:收到 tool 但迟迟没有 block 时,启动兜底定时器
767
- // 续期有上限(maxToolRenewals 次),防止无限工具调用永远不触发兜底
768
811
  if (toolOnlyTimeoutId) {
769
- if (toolRenewalCount < maxToolRenewals) {
770
- clearTimeout(toolOnlyTimeoutId);
771
- toolRenewalCount++;
772
- log?.info(`[qqbot:${account.accountId}] Tool-only timer renewed (${toolRenewalCount}/${maxToolRenewals})`);
773
- }
774
- else {
775
- // 已达续期上限,不再重置,等定时器自然触发兜底
776
- log?.info(`[qqbot:${account.accountId}] Tool-only timer renewal limit reached (${maxToolRenewals}), waiting for timeout`);
777
- return;
778
- }
812
+ clearTimeout(toolOnlyTimeoutId);
813
+ toolOnlyTimeoutId = null;
779
814
  }
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}`);
815
+ if (toolDeliverCount > 0) {
816
+ log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
817
+ }
818
+ // ============ 实际发送逻辑(可被 debouncer 包裹) ============
819
+ const executeDeliver = async (deliverPayload, _deliverInfo) => {
820
+ // ============ 引用回复 ============
821
+ const quoteRef = event.msgIdx;
822
+ let quoteRefUsed = false;
823
+ const consumeQuoteRef = () => {
824
+ if (quoteRef && !quoteRefUsed) {
825
+ quoteRefUsed = true;
826
+ return quoteRef;
789
827
  }
828
+ return undefined;
829
+ };
830
+ let replyText = deliverPayload.text ?? "";
831
+ // ============ 媒体标签解析 + 发送 ============
832
+ const deliverEvent = {
833
+ type: event.type,
834
+ senderId: event.senderId,
835
+ messageId: event.messageId,
836
+ channelId: event.channelId,
837
+ groupOpenid: event.groupOpenid,
838
+ msgIdx: event.msgIdx,
839
+ };
840
+ const deliverActx = { account, qualifiedTarget, log };
841
+ const mediaResult = await parseAndSendMediaTags(replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef);
842
+ if (mediaResult.handled) {
843
+ pluginRuntime.channel.activity.record({
844
+ channel: "qqbot",
845
+ accountId: account.accountId,
846
+ direction: "outbound",
847
+ });
848
+ return;
790
849
  }
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;
850
+ replyText = mediaResult.normalizedText;
851
+ // ============ 结构化载荷检测与分发 ============
852
+ const recordOutboundActivity = () => pluginRuntime.channel.activity.record({
853
+ channel: "qqbot",
854
+ accountId: account.accountId,
855
+ direction: "outbound",
856
+ });
857
+ const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
858
+ if (handled)
859
+ return;
860
+ // ============ 非结构化消息发送 ============
861
+ await sendPlainReply(deliverPayload, replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef, toolMediaUrls);
862
+ pluginRuntime.channel.activity.record({
863
+ channel: "qqbot",
864
+ accountId: account.accountId,
865
+ direction: "outbound",
866
+ });
867
+ };
868
+ // ============ Debounce 合并回复 ============
869
+ if (!debouncer) {
870
+ debouncer = createDeliverDebouncer(debounceConfig, executeDeliver, log, `[qqbot:${account.accountId}:debounce]`);
816
871
  }
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
- });
872
+ if (debouncer) {
873
+ await debouncer.deliver(payload, info);
874
+ }
875
+ else {
876
+ await executeDeliver(payload, info);
877
+ }
878
+ },
879
+ onError: async (err) => {
880
+ log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
881
+ hasResponse = true;
882
+ if (timeoutId) {
883
+ clearTimeout(timeoutId);
884
+ timeoutId = null;
885
+ }
886
+ // 发送错误提示给用户,显示完整错误信息
887
+ const errMsg = String(err);
888
+ if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
889
+ log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
890
+ }
891
+ else {
892
+ log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
893
+ }
894
+ },
856
895
  },
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}`);
868
- }
869
- else {
870
- log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
871
- }
896
+ replyOptions: {
897
+ disableBlockStreaming: true,
872
898
  },
873
- },
874
- replyOptions: {
875
- disableBlockStreaming: true,
876
- },
877
- });
878
- // 等待分发完成或超时
879
- try {
880
- await Promise.race([dispatchPromise, timeoutPromise]);
881
- }
882
- catch (err) {
883
- if (timeoutId) {
884
- clearTimeout(timeoutId);
899
+ });
900
+ // 等待分发完成或超时
901
+ try {
902
+ await Promise.race([dispatchPromise, timeoutPromise]);
885
903
  }
886
- if (!hasResponse) {
887
- log?.error(`[qqbot:${account.accountId}] No response within timeout`);
904
+ catch (err) {
905
+ if (timeoutId) {
906
+ clearTimeout(timeoutId);
907
+ }
908
+ if (!hasResponse) {
909
+ log?.error(`[qqbot:${account.accountId}] No response within timeout`);
910
+ }
888
911
  }
912
+ finally {
913
+ // 清理 tool-only 兜底定时器
914
+ if (toolOnlyTimeoutId) {
915
+ clearTimeout(toolOnlyTimeoutId);
916
+ toolOnlyTimeoutId = null;
917
+ }
918
+ // dispatch 完成后,如果只有 tool 没有 block,且尚未发过兜底,立即兜底
919
+ if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
920
+ toolFallbackSent = true;
921
+ log?.error(`[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`);
922
+ await sendToolFallback();
923
+ }
924
+ // 销毁 debouncer,flush 剩余缓冲的文本
925
+ if (debouncer) {
926
+ await debouncer.dispose();
927
+ debouncer = null;
928
+ }
929
+ }
930
+ }
931
+ catch (err) {
932
+ log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
889
933
  }
890
934
  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
- }
935
+ // 无论成功/失败/超时,都停止输入状态续期
936
+ typing.keepAlive?.stop();
902
937
  }
903
- }
904
- catch (err) {
905
- log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
906
- }
907
- finally {
908
- // 无论成功/失败/超时,都停止输入状态续期
909
- typing.keepAlive?.stop();
910
- }
938
+ }); // end runWithRequestContext
911
939
  };
912
940
  ws.on("open", () => {
913
941
  log?.info(`[qqbot:${account.accountId}] WebSocket connected`);
@@ -999,11 +1027,11 @@ export async function startGateway(ctx) {
999
1027
  onReady?.(d);
1000
1028
  // 仅 startGateway 后的首次 READY 才发送上线通知
1001
1029
  // ws 断线重连(resume 失败后重新 Identify)产生的 READY 不发送
1002
- if (!isFirstReadyGlobal) {
1030
+ if (!_pendingFirstReady.has(account.accountId)) {
1003
1031
  log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (reconnect READY, not first startup)`);
1004
1032
  }
1005
1033
  else {
1006
- isFirstReadyGlobal = false;
1034
+ _pendingFirstReady.delete(account.accountId);
1007
1035
  sendStartupGreetings(adminCtx, "READY");
1008
1036
  } // end isFirstReady
1009
1037
  }
@@ -1011,8 +1039,8 @@ export async function startGateway(ctx) {
1011
1039
  log?.info(`[qqbot:${account.accountId}] Session resumed`);
1012
1040
  onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
1013
1041
  // RESUMED 也属于首次启动(gateway restart 通常走 resume)
1014
- if (isFirstReadyGlobal) {
1015
- isFirstReadyGlobal = false;
1042
+ if (_pendingFirstReady.has(account.accountId)) {
1043
+ _pendingFirstReady.delete(account.accountId);
1016
1044
  sendStartupGreetings(adminCtx, "RESUMED");
1017
1045
  }
1018
1046
  // P1-2: 更新 Session 连接时间