@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.
- package/README.md +2 -15
- package/README.zh.md +3 -16
- package/dist/src/admin-resolver.d.ts +12 -6
- package/dist/src/admin-resolver.js +69 -34
- package/dist/src/api.d.ts +105 -1
- package/dist/src/api.js +164 -15
- package/dist/src/channel.js +13 -0
- package/dist/src/config.js +3 -10
- package/dist/src/deliver-debounce.d.ts +74 -0
- package/dist/src/deliver-debounce.js +174 -0
- package/dist/src/gateway.js +450 -248
- package/dist/src/image-server.d.ts +27 -8
- package/dist/src/image-server.js +179 -71
- package/dist/src/inbound-attachments.d.ts +3 -1
- package/dist/src/inbound-attachments.js +28 -14
- package/dist/src/outbound-deliver.js +77 -148
- package/dist/src/outbound.d.ts +6 -4
- package/dist/src/outbound.js +266 -442
- package/dist/src/reply-dispatcher.js +4 -4
- package/dist/src/request-context.d.ts +18 -0
- package/dist/src/request-context.js +30 -0
- package/dist/src/slash-commands.js +277 -32
- package/dist/src/startup-greeting.d.ts +5 -5
- package/dist/src/startup-greeting.js +32 -13
- package/dist/src/streaming.d.ts +244 -0
- package/dist/src/streaming.js +907 -0
- package/dist/src/tools/remind.js +11 -10
- package/dist/src/types.d.ts +101 -0
- package/dist/src/types.js +17 -1
- package/dist/src/update-checker.js +2 -8
- package/dist/src/utils/audio-convert.d.ts +9 -0
- package/dist/src/utils/audio-convert.js +51 -0
- package/dist/src/utils/chunked-upload.d.ts +59 -0
- package/dist/src/utils/chunked-upload.js +289 -0
- package/dist/src/utils/file-utils.d.ts +7 -1
- package/dist/src/utils/file-utils.js +24 -2
- package/dist/src/utils/media-send.d.ts +147 -0
- package/dist/src/utils/media-send.js +434 -0
- package/dist/src/utils/pkg-version.d.ts +5 -0
- package/dist/src/utils/pkg-version.js +51 -0
- package/dist/src/utils/ssrf-guard.d.ts +25 -0
- package/dist/src/utils/ssrf-guard.js +91 -0
- package/node_modules/ws/index.js +15 -6
- package/node_modules/ws/lib/permessage-deflate.js +6 -6
- package/node_modules/ws/lib/websocket-server.js +5 -5
- package/node_modules/ws/lib/websocket.js +6 -6
- package/node_modules/ws/package.json +4 -3
- package/node_modules/ws/wrapper.mjs +14 -1
- package/openclaw.plugin.json +1 -0
- package/package.json +11 -22
- package/scripts/postinstall-link-sdk.js +113 -0
- package/scripts/upgrade-via-npm.ps1 +161 -6
- package/scripts/upgrade-via-npm.sh +311 -104
- package/scripts/upgrade-via-source.sh +117 -0
- package/skills/qqbot-media/SKILL.md +9 -5
- package/skills/qqbot-remind/SKILL.md +3 -3
- package/src/admin-resolver.ts +76 -35
- package/src/api.ts +284 -12
- package/src/channel.ts +12 -0
- package/src/config.ts +3 -10
- package/src/deliver-debounce.ts +229 -0
- package/src/gateway.ts +277 -67
- package/src/image-server.ts +213 -77
- package/src/inbound-attachments.ts +32 -15
- package/src/outbound-deliver.ts +77 -157
- package/src/outbound.ts +304 -451
- package/src/reply-dispatcher.ts +4 -4
- package/src/request-context.ts +39 -0
- package/src/slash-commands.ts +303 -33
- package/src/startup-greeting.ts +35 -13
- package/src/streaming.ts +1096 -0
- package/src/tools/remind.ts +15 -11
- package/src/types.ts +111 -0
- package/src/update-checker.ts +2 -7
- package/src/utils/audio-convert.ts +56 -0
- package/src/utils/chunked-upload.ts +419 -0
- package/src/utils/file-utils.ts +28 -2
- package/src/utils/media-send.ts +563 -0
- package/src/utils/pkg-version.ts +54 -0
- package/src/utils/ssrf-guard.ts +102 -0
- package/clawdbot.plugin.json +0 -16
- package/dist/src/user-messages.d.ts +0 -8
- package/dist/src/user-messages.js +0 -8
- package/moltbot.plugin.json +0 -16
- package/scripts/upgrade-via-alt-pkg.sh +0 -307
- package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
- package/src/gateway.log +0 -43
- package/src/openclaw-2026-03-21.log +0 -3729
- package/src/user-messages.ts +0 -7
package/dist/src/gateway.js
CHANGED
|
@@ -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
|
-
//
|
|
71
|
+
// 模块级变量:per-account 首次 READY 跟踪
|
|
69
72
|
// 区分 gateway restart(进程重启)和 health-monitor 断线重连
|
|
70
|
-
|
|
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
|
-
//
|
|
162
|
-
|
|
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, {
|
|
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
|
-
//
|
|
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
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
689
|
-
log?.error(`[qqbot:${account.accountId}] Tool fallback sendMedia failed: ${err}`);
|
|
690
|
-
}
|
|
719
|
+
return;
|
|
691
720
|
}
|
|
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"));
|
|
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
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
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
|
-
|
|
753
|
-
log?.
|
|
831
|
+
catch (err) {
|
|
832
|
+
log?.error(`[qqbot:${account.accountId}] Tool media immediate forward failed: ${err}`);
|
|
754
833
|
}
|
|
755
834
|
}
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
870
|
-
log?.error(`[qqbot:${account.accountId}]
|
|
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
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
if (
|
|
887
|
-
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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 (!
|
|
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
|
-
|
|
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 (
|
|
1015
|
-
|
|
1216
|
+
if (_pendingFirstReady.has(account.accountId)) {
|
|
1217
|
+
_pendingFirstReady.delete(account.accountId);
|
|
1016
1218
|
sendStartupGreetings(adminCtx, "RESUMED");
|
|
1017
1219
|
}
|
|
1018
1220
|
// P1-2: 更新 Session 连接时间
|