@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.
- package/README.md +3 -10
- package/README.zh.md +3 -10
- package/dist/src/admin-resolver.d.ts +12 -6
- package/dist/src/admin-resolver.js +61 -24
- package/dist/src/deliver-debounce.d.ts +74 -0
- package/dist/src/deliver-debounce.js +174 -0
- package/dist/src/gateway.js +281 -253
- package/dist/src/request-context.d.ts +18 -0
- package/dist/src/request-context.js +30 -0
- package/dist/src/startup-greeting.d.ts +5 -5
- package/dist/src/startup-greeting.js +32 -13
- package/dist/src/tools/remind.js +11 -10
- package/dist/src/types.d.ts +30 -0
- package/package.json +1 -1
- package/scripts/upgrade-via-npm.ps1 +119 -6
- package/scripts/upgrade-via-npm.sh +121 -7
- package/skills/qqbot-remind/SKILL.md +3 -3
- package/src/admin-resolver.ts +67 -25
- package/src/deliver-debounce.ts +229 -0
- package/src/gateway.ts +97 -62
- package/src/request-context.ts +39 -0
- package/src/startup-greeting.ts +35 -13
- package/src/tools/remind.ts +15 -11
- package/src/types.ts +31 -0
package/dist/src/gateway.js
CHANGED
|
@@ -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
|
-
//
|
|
70
|
+
// 模块级变量:per-account 首次 READY 跟踪
|
|
69
71
|
// 区分 gateway restart(进程重启)和 health-monitor 断线重连
|
|
70
|
-
|
|
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
|
-
//
|
|
162
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
689
|
-
log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${err}`);
|
|
690
|
-
}
|
|
701
|
+
return;
|
|
691
702
|
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
toolMediaUrls.
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
753
|
-
log?.
|
|
765
|
+
catch (err) {
|
|
766
|
+
log?.error(`[qqbot:${account.accountId}] Tool media immediate forward failed: ${err}`);
|
|
754
767
|
}
|
|
755
768
|
}
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
770
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
accountId:
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
858
|
-
|
|
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
|
-
|
|
875
|
-
|
|
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
|
-
|
|
887
|
-
|
|
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
|
-
//
|
|
892
|
-
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
1015
|
-
|
|
1042
|
+
if (_pendingFirstReady.has(account.accountId)) {
|
|
1043
|
+
_pendingFirstReady.delete(account.accountId);
|
|
1016
1044
|
sendStartupGreetings(adminCtx, "RESUMED");
|
|
1017
1045
|
}
|
|
1018
1046
|
// P1-2: 更新 Session 连接时间
|