@ryantest/openclaw-qqbot 0.0.3 → 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/src/gateway.ts
CHANGED
|
@@ -20,6 +20,9 @@ import { sendStartupGreetings, type AdminResolverContext } from "./admin-resolve
|
|
|
20
20
|
import { sendWithTokenRetry, sendErrorToTarget, handleStructuredPayload, type ReplyContext, type MessageTarget } from "./reply-dispatcher.js";
|
|
21
21
|
import { TypingKeepAlive, TYPING_INPUT_SECOND } from "./typing-keepalive.js";
|
|
22
22
|
import { parseAndSendMediaTags, sendPlainReply, type DeliverEventContext, type DeliverAccountContext } from "./outbound-deliver.js";
|
|
23
|
+
import { createDeliverDebouncer, type DeliverDebouncer } from "./deliver-debounce.js";
|
|
24
|
+
import { runWithRequestContext } from "./request-context.js";
|
|
25
|
+
import { StreamingController, shouldUseStreaming } from "./streaming.js";
|
|
23
26
|
|
|
24
27
|
// QQ Bot intents - 按权限级别分组
|
|
25
28
|
const INTENTS = {
|
|
@@ -48,6 +51,7 @@ const IMAGE_SERVER_PORT = parseInt(process.env.QQBOT_IMAGE_SERVER_PORT || "18765
|
|
|
48
51
|
// 使用绝对路径,确保文件保存和读取使用同一目录
|
|
49
52
|
const IMAGE_SERVER_DIR = process.env.QQBOT_IMAGE_SERVER_DIR || getQQBotDataDir("images");
|
|
50
53
|
|
|
54
|
+
|
|
51
55
|
export interface GatewayContext {
|
|
52
56
|
account: ResolvedQQBotAccount;
|
|
53
57
|
abortSignal: AbortSignal;
|
|
@@ -86,9 +90,10 @@ async function ensureImageServer(log?: GatewayContext["log"], publicBaseUrl?: st
|
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
|
|
89
|
-
//
|
|
93
|
+
// 模块级变量:per-account 首次 READY 跟踪
|
|
90
94
|
// 区分 gateway restart(进程重启)和 health-monitor 断线重连
|
|
91
|
-
|
|
95
|
+
// 每个 account 首次 READY/RESUMED 时从 Set 中移除,之后不再发送问候语
|
|
96
|
+
const _pendingFirstReady = new Set<string>();
|
|
92
97
|
|
|
93
98
|
/**
|
|
94
99
|
* 启动 Gateway WebSocket 连接(带自动重连)
|
|
@@ -109,6 +114,21 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
109
114
|
}
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
// 预检 openclaw runtime 模块是否可正常解析(兼容性诊断)
|
|
118
|
+
// openclaw 3.23+ 存在 plugin-sdk/root-alias.cjs 回归 bug,
|
|
119
|
+
// 内置插件(qwen-portal-auth 等)全部加载失败,导致 AI agent 调用返回
|
|
120
|
+
// "Unable to resolve plugin runtime module"。提前检测并告警。
|
|
121
|
+
try {
|
|
122
|
+
const pluginRuntime = getQQBotRuntime();
|
|
123
|
+
if (pluginRuntime?.channel?.reply?.dispatchReplyWithBufferedBlockDispatcher) {
|
|
124
|
+
log?.info(`[qqbot:${account.accountId}] Runtime module preflight: OK`);
|
|
125
|
+
} else {
|
|
126
|
+
log?.error(`[qqbot:${account.accountId}] ⚠️ Runtime preflight: dispatchReply API 不可用,AI 消息处理可能失败。请检查 openclaw 版本兼容性`);
|
|
127
|
+
}
|
|
128
|
+
} catch (preflightErr) {
|
|
129
|
+
log?.error(`[qqbot:${account.accountId}] ⚠️ Runtime preflight failed: ${preflightErr}. AI 消息处理可能失败`);
|
|
130
|
+
}
|
|
131
|
+
|
|
112
132
|
// 后台版本检查(供 /bot-version、/bot-upgrade 指令被动查询)
|
|
113
133
|
triggerUpdateCheck(log);
|
|
114
134
|
|
|
@@ -186,8 +206,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
186
206
|
let isConnecting = false; // 防止并发连接
|
|
187
207
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; // 重连定时器
|
|
188
208
|
let shouldRefreshToken = false; // 下次连接是否需要刷新 token
|
|
189
|
-
//
|
|
190
|
-
|
|
209
|
+
// 标记此 account 为待发问候(进程重启时 Set 里已有,断线重连不会重新加入)
|
|
210
|
+
_pendingFirstReady.add(account.accountId);
|
|
191
211
|
|
|
192
212
|
const adminCtx: AdminResolverContext = { accountId: account.accountId, appId: account.appId, clientSecret: account.clientSecret, log };
|
|
193
213
|
|
|
@@ -501,7 +521,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
501
521
|
}
|
|
502
522
|
|
|
503
523
|
// 处理附件(图片等)- 下载到本地供 openclaw 访问
|
|
504
|
-
const processed = await processAttachments(event.attachments, {
|
|
524
|
+
const processed = await processAttachments(event.attachments, { appId: account.appId, peerId, cfg, log });
|
|
505
525
|
const { attachmentInfo, imageUrls, imageMediaTypes, voiceAttachmentPaths, voiceAttachmentUrls, voiceAsrReferTexts, voiceTranscripts, voiceTranscriptSources, attachmentLocalPaths } = processed;
|
|
506
526
|
|
|
507
527
|
// 语音转录文本注入到用户消息中
|
|
@@ -621,7 +641,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
621
641
|
|
|
622
642
|
// ============ 构建 contextInfo(静态/动态分离) ============
|
|
623
643
|
// 设计原则(参考 Telegram/Discord 做法):
|
|
624
|
-
// -
|
|
644
|
+
// - 静态指引:每条消息不变的能力声明,
|
|
625
645
|
// 注入 systemPrompts 前部,session 中虽重复出现但 AI 会自动降权,
|
|
626
646
|
// 且保证长 session 窗口截断后仍可见。
|
|
627
647
|
// - 动态标签:每条消息变化的数据(时间、附件、ASR),
|
|
@@ -629,17 +649,17 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
629
649
|
|
|
630
650
|
// --- 静态指引(仅注入框架信封未覆盖的 QQBot 特有信息) ---
|
|
631
651
|
// 框架 formatInboundEnvelope 已提供:平台标识、发送者、时间戳
|
|
632
|
-
//
|
|
633
|
-
const staticParts: string[] = [
|
|
634
|
-
`[QQBot] to=${qualifiedTarget}`,
|
|
635
|
-
];
|
|
652
|
+
// 投递地址通过 AsyncLocalStorage 请求上下文传递给 remind 工具,无需在 agentBody 中暴露
|
|
653
|
+
const staticParts: string[] = [];
|
|
636
654
|
// TTS 能力声明:仅在启用时告知 AI 可以发语音(媒体标签用法由 qqbot-media SKILL.md 提供)
|
|
637
655
|
// STT 无需声明:转写结果已在动态上下文的 ASR 行中,AI 自然可见
|
|
638
656
|
if (hasTTS) staticParts.push("语音合成已启用");
|
|
639
|
-
const staticInstruction = staticParts.join(" | ");
|
|
640
657
|
|
|
641
|
-
//
|
|
642
|
-
|
|
658
|
+
// 仅在有静态指引时注入 systemPrompts
|
|
659
|
+
if (staticParts.length > 0) {
|
|
660
|
+
const staticInstruction = staticParts.join(" | ");
|
|
661
|
+
systemPrompts.unshift(staticInstruction);
|
|
662
|
+
}
|
|
643
663
|
|
|
644
664
|
// --- 动态上下文(仅框架信封未覆盖的附件信息) ---
|
|
645
665
|
const dynLines: string[] = [];
|
|
@@ -757,6 +777,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
757
777
|
// 发送错误提示的辅助函数
|
|
758
778
|
const sendErrorMessage = (errorText: string) => sendErrorToTarget(replyCtx, errorText);
|
|
759
779
|
|
|
780
|
+
// 使用 AsyncLocalStorage 建立请求级上下文,作用域内所有异步代码
|
|
781
|
+
// (包括 AI agent 调用、tool execute)都能安全获取当前会话信息,无并发竞态。
|
|
782
|
+
await runWithRequestContext({ target: qualifiedTarget }, async () => {
|
|
760
783
|
try {
|
|
761
784
|
const messagesConfig = pluginRuntime.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId);
|
|
762
785
|
|
|
@@ -767,6 +790,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
767
790
|
const toolTexts: string[] = []; // 收集所有 tool deliver 文本
|
|
768
791
|
const toolMediaUrls: string[] = []; // 收集所有 tool deliver 媒体 URL
|
|
769
792
|
let toolFallbackSent = false; // 兜底消息是否已发送(只发一次)
|
|
793
|
+
const blockDeliveredMediaUrls = new Set<string>(); // block deliver 已处理的 mediaUrl,用于 tool 后到时去重
|
|
770
794
|
const responseTimeout = 120000; // 120秒超时(2分钟,与 TTS/文件生成超时对齐)
|
|
771
795
|
const toolOnlyTimeout = 60000; // tool-only 兜底超时:60秒内没有 block 就兜底
|
|
772
796
|
const maxToolRenewals = 3; // tool 续期上限:最多续期 3 次(总等待 = 60s × 3 = 180s)
|
|
@@ -774,6 +798,10 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
774
798
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
775
799
|
let toolOnlyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
776
800
|
|
|
801
|
+
// ============ Deliver Debouncer:合并短时间内连续到达的 block deliver ============
|
|
802
|
+
const debounceConfig = account.config?.deliverDebounce;
|
|
803
|
+
let debouncer: DeliverDebouncer | null = null as DeliverDebouncer | null;
|
|
804
|
+
|
|
777
805
|
// tool-only 兜底:转发工具产生的实际内容(媒体/文本),而非生硬的提示语
|
|
778
806
|
const sendToolFallback = async (): Promise<void> => {
|
|
779
807
|
// 优先发送工具产出的媒体文件(TTS 语音、生成图片等)
|
|
@@ -823,6 +851,53 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
823
851
|
}, responseTimeout);
|
|
824
852
|
});
|
|
825
853
|
|
|
854
|
+
|
|
855
|
+
// ============ 流式消息控制器 ============
|
|
856
|
+
const targetType = event.type === "c2c" ? "c2c" as const
|
|
857
|
+
: event.type === "group" ? "group" as const
|
|
858
|
+
: "channel" as const;
|
|
859
|
+
const useStreaming = shouldUseStreaming(account, targetType);
|
|
860
|
+
log?.info(`[qqbot:${account.accountId}] Streaming ${useStreaming ? "enabled" : "disabled"} for ${targetType} message from ${event.senderId}`);
|
|
861
|
+
let streamingController: StreamingController | null = null;
|
|
862
|
+
|
|
863
|
+
/** 创建一个新的 StreamingController 实例(用于初始创建和回复边界时重建) */
|
|
864
|
+
const createStreamingController = (): StreamingController => {
|
|
865
|
+
const ctrl = new StreamingController({
|
|
866
|
+
account,
|
|
867
|
+
userId: event.senderId,
|
|
868
|
+
replyToMsgId: event.messageId,
|
|
869
|
+
eventId: event.messageId,
|
|
870
|
+
logPrefix: `[qqbot:${account.accountId}:streaming]`,
|
|
871
|
+
log,
|
|
872
|
+
mediaContext: {
|
|
873
|
+
account,
|
|
874
|
+
event: {
|
|
875
|
+
type: event.type as "c2c" | "group" | "channel",
|
|
876
|
+
senderId: event.senderId,
|
|
877
|
+
messageId: event.messageId,
|
|
878
|
+
groupOpenid: event.groupOpenid,
|
|
879
|
+
channelId: event.channelId,
|
|
880
|
+
},
|
|
881
|
+
log,
|
|
882
|
+
},
|
|
883
|
+
// 回复边界回调:终结旧 controller 后创建新的,用新回复文本继续流式
|
|
884
|
+
onReplyBoundary: async (newReplyText: string) => {
|
|
885
|
+
log?.info(`[qqbot:${account.accountId}] Reply boundary: creating new StreamingController for new reply`);
|
|
886
|
+
const newCtrl = createStreamingController();
|
|
887
|
+
streamingController = newCtrl;
|
|
888
|
+
// 将新回复的初始文本交给新 controller 处理
|
|
889
|
+
await newCtrl.onPartialReply({ text: newReplyText });
|
|
890
|
+
},
|
|
891
|
+
});
|
|
892
|
+
return ctrl;
|
|
893
|
+
};
|
|
894
|
+
|
|
895
|
+
if (useStreaming) {
|
|
896
|
+
log?.info(`[qqbot:${account.accountId}] Streaming mode enabled for ${targetType} target`);
|
|
897
|
+
streamingController = createStreamingController();
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
|
|
826
901
|
const dispatchPromise = pluginRuntime.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
827
902
|
ctx: ctxPayload,
|
|
828
903
|
cfg,
|
|
@@ -851,9 +926,15 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
851
926
|
|
|
852
927
|
// block 已先发送完毕,tool 后到的媒体立即转发(典型场景:AI 先流式输出文本再执行 TTS)
|
|
853
928
|
if (hasBlockResponse && toolMediaUrls.length > 0) {
|
|
854
|
-
|
|
855
|
-
const urlsToSend =
|
|
929
|
+
// 去重:跳过已被 block deliver 的 sendPlainReply 处理过的 URL
|
|
930
|
+
const urlsToSend = toolMediaUrls.filter(url => !blockDeliveredMediaUrls.has(url));
|
|
931
|
+
const skippedCount = toolMediaUrls.length - urlsToSend.length;
|
|
856
932
|
toolMediaUrls.length = 0;
|
|
933
|
+
if (urlsToSend.length === 0) {
|
|
934
|
+
log?.info(`[qqbot:${account.accountId}] All ${skippedCount} tool media URL(s) already handled by block deliver, skipping`);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
log?.info(`[qqbot:${account.accountId}] Block already sent, immediately forwarding ${urlsToSend.length} tool media URL(s) (deduped from block deliver)`);
|
|
857
938
|
for (const mediaUrl of urlsToSend) {
|
|
858
939
|
try {
|
|
859
940
|
const result = await sendMediaAuto({
|
|
@@ -924,63 +1005,117 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
924
1005
|
log?.info(`[qqbot:${account.accountId}] Block deliver after ${toolDeliverCount} tool deliver(s)`);
|
|
925
1006
|
}
|
|
926
1007
|
|
|
927
|
-
// ============
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1008
|
+
// ============ 流式模式处理 ============
|
|
1009
|
+
// 流式模式下,所有 block deliver 内容(含媒体标签)统一交由 StreamingController 处理。
|
|
1010
|
+
// StreamingController 内部有重试机制;如果一个分片都没发出去则降级到普通消息。
|
|
1011
|
+
if (streamingController && !streamingController.isTerminalPhase) {
|
|
1012
|
+
const deliverTextLen = (payload.text ?? "").length;
|
|
1013
|
+
const deliverPreview = (payload.text ?? "").slice(0, 40).replace(/\n/g, "\\n");
|
|
1014
|
+
log?.debug?.(`[qqbot:${account.accountId}] Streaming deliver entry, textLen=${deliverTextLen}, phase=${streamingController.currentPhase}, sentChunks=${streamingController.sentChunkCount_debug}, preview="${deliverPreview}"`);
|
|
1015
|
+
try {
|
|
1016
|
+
await streamingController.onDeliver(payload);
|
|
1017
|
+
log?.debug?.(`[qqbot:${account.accountId}] Streaming deliver done, phase=${streamingController.currentPhase}`);
|
|
1018
|
+
} catch (err) {
|
|
1019
|
+
// StreamingController 内部已有重试,这里只打日志
|
|
1020
|
+
log?.error(`[qqbot:${account.accountId}] Streaming deliver error: ${err}`);
|
|
934
1021
|
}
|
|
935
|
-
return undefined;
|
|
936
|
-
};
|
|
937
1022
|
|
|
938
|
-
|
|
1023
|
+
// 检查是否因流式 API 不可用而需要降级(ensureStreamingStarted 全部失败)
|
|
1024
|
+
// 如果需要降级,不 return,让本次 deliver 的 payload.text(全量文本)继续走普通发送逻辑
|
|
1025
|
+
if (streamingController.shouldFallbackToStatic) {
|
|
1026
|
+
log?.info(`[qqbot:${account.accountId}] Streaming API unavailable, falling back to static for this deliver`);
|
|
1027
|
+
// 不 return,继续走普通发送逻辑(payload.text 是完整文本)
|
|
1028
|
+
} else {
|
|
1029
|
+
// 流式正常处理,不走普通发送逻辑
|
|
1030
|
+
pluginRuntime.channel.activity.record({
|
|
1031
|
+
channel: "qqbot",
|
|
1032
|
+
accountId: account.accountId,
|
|
1033
|
+
direction: "outbound",
|
|
1034
|
+
});
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
939
1038
|
|
|
940
|
-
// ============
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1039
|
+
// ============ 实际发送逻辑(可被 debouncer 包裹) ============
|
|
1040
|
+
const executeDeliver = async (deliverPayload: { text?: string; mediaUrls?: string[]; mediaUrl?: string }, _deliverInfo: { kind: string }) => {
|
|
1041
|
+
// ============ 引用回复 ============
|
|
1042
|
+
const quoteRef = event.msgIdx;
|
|
1043
|
+
let quoteRefUsed = false;
|
|
1044
|
+
const consumeQuoteRef = (): string | undefined => {
|
|
1045
|
+
if (quoteRef && !quoteRefUsed) {
|
|
1046
|
+
quoteRefUsed = true;
|
|
1047
|
+
return quoteRef;
|
|
1048
|
+
}
|
|
1049
|
+
return undefined;
|
|
1050
|
+
};
|
|
1051
|
+
|
|
1052
|
+
let replyText = deliverPayload.text ?? "";
|
|
1053
|
+
|
|
1054
|
+
// ============ 媒体标签解析 + 发送 ============
|
|
1055
|
+
const deliverEvent: DeliverEventContext = {
|
|
1056
|
+
type: event.type,
|
|
1057
|
+
senderId: event.senderId,
|
|
1058
|
+
messageId: event.messageId,
|
|
1059
|
+
channelId: event.channelId,
|
|
1060
|
+
groupOpenid: event.groupOpenid,
|
|
1061
|
+
msgIdx: event.msgIdx,
|
|
1062
|
+
};
|
|
1063
|
+
const deliverActx: DeliverAccountContext = { account, qualifiedTarget, log };
|
|
1064
|
+
|
|
1065
|
+
const mediaResult = await parseAndSendMediaTags(
|
|
1066
|
+
replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef,
|
|
1067
|
+
);
|
|
1068
|
+
if (mediaResult.handled) {
|
|
1069
|
+
pluginRuntime.channel.activity.record({
|
|
1070
|
+
channel: "qqbot",
|
|
1071
|
+
accountId: account.accountId,
|
|
1072
|
+
direction: "outbound",
|
|
1073
|
+
});
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
replyText = mediaResult.normalizedText;
|
|
950
1077
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
);
|
|
954
|
-
if (mediaResult.handled) {
|
|
955
|
-
pluginRuntime.channel.activity.record({
|
|
1078
|
+
// ============ 结构化载荷检测与分发 ============
|
|
1079
|
+
const recordOutboundActivity = () => pluginRuntime.channel.activity.record({
|
|
956
1080
|
channel: "qqbot",
|
|
957
1081
|
accountId: account.accountId,
|
|
958
1082
|
direction: "outbound",
|
|
959
1083
|
});
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
replyText = mediaResult.normalizedText;
|
|
1084
|
+
const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
|
|
1085
|
+
if (handled) return;
|
|
963
1086
|
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
direction: "outbound",
|
|
969
|
-
});
|
|
970
|
-
const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
|
|
971
|
-
if (handled) return;
|
|
1087
|
+
// ============ 非结构化消息发送 ============
|
|
1088
|
+
// 记录 block deliver 处理的 mediaUrl,供 tool 后到时去重
|
|
1089
|
+
if (deliverPayload.mediaUrl) blockDeliveredMediaUrls.add(deliverPayload.mediaUrl);
|
|
1090
|
+
if (deliverPayload.mediaUrls) for (const u of deliverPayload.mediaUrls) blockDeliveredMediaUrls.add(u);
|
|
972
1091
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
);
|
|
1092
|
+
await sendPlainReply(
|
|
1093
|
+
deliverPayload, replyText, deliverEvent, deliverActx,
|
|
1094
|
+
sendWithRetry, consumeQuoteRef, toolMediaUrls,
|
|
1095
|
+
);
|
|
978
1096
|
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1097
|
+
pluginRuntime.channel.activity.record({
|
|
1098
|
+
channel: "qqbot",
|
|
1099
|
+
accountId: account.accountId,
|
|
1100
|
+
direction: "outbound",
|
|
1101
|
+
});
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
// ============ Debounce 合并回复 ============
|
|
1105
|
+
if (!debouncer) {
|
|
1106
|
+
debouncer = createDeliverDebouncer(
|
|
1107
|
+
debounceConfig,
|
|
1108
|
+
executeDeliver,
|
|
1109
|
+
log,
|
|
1110
|
+
`[qqbot:${account.accountId}:debounce]`,
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
if (debouncer) {
|
|
1115
|
+
await debouncer.deliver(payload, info);
|
|
1116
|
+
} else {
|
|
1117
|
+
await executeDeliver(payload, info);
|
|
1118
|
+
}
|
|
984
1119
|
},
|
|
985
1120
|
onError: async (err: unknown) => {
|
|
986
1121
|
log?.error(`[qqbot:${account.accountId}] Dispatch error: ${err}`);
|
|
@@ -989,9 +1124,33 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
989
1124
|
clearTimeout(timeoutId);
|
|
990
1125
|
timeoutId = null;
|
|
991
1126
|
}
|
|
1127
|
+
|
|
1128
|
+
// 流式模式:委托给 streaming controller 处理错误
|
|
1129
|
+
if (streamingController && !streamingController.isTerminalPhase) {
|
|
1130
|
+
try {
|
|
1131
|
+
await streamingController.onError(err);
|
|
1132
|
+
} catch (streamErr) {
|
|
1133
|
+
log?.error(`[qqbot:${account.accountId}] Streaming onError failed: ${streamErr}`);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// 如果 onError 中因无分片发出而降级,不 return,走普通错误处理
|
|
1137
|
+
if (streamingController.shouldFallbackToStatic) {
|
|
1138
|
+
log?.info(`[qqbot:${account.accountId}] Streaming onError: no chunk sent, falling back to static error handling`);
|
|
1139
|
+
// 不 return,继续走普通错误处理
|
|
1140
|
+
} else {
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
992
1144
|
|
|
993
|
-
// 发送错误提示给用户,显示完整错误信息
|
|
994
1145
|
const errMsg = String(err);
|
|
1146
|
+
|
|
1147
|
+
// 兼容 openclaw 3.23+ 的 plugin-sdk/root-alias.cjs 模块解析失败
|
|
1148
|
+
if (errMsg.includes("Unable to resolve plugin runtime module") || errMsg.includes("root-alias.cjs")) {
|
|
1149
|
+
log?.error(`[qqbot:${account.accountId}] ⚠️ openclaw 框架 runtime 模块解析失败,可能是 openclaw 版本与 plugin-sdk 不兼容。请尝试: npm install -g openclaw@latest && openclaw gateway restart`);
|
|
1150
|
+
await sendErrorMessage("⚠️ AI 服务暂时不可用:openclaw 框架运行时模块加载失败。\n\n请管理员执行:\nnpm install -g openclaw@latest\nopenclaw gateway restart\n\n斜杠命令(如 /bot-ping)不受影响。");
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
995
1154
|
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
|
996
1155
|
log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
|
|
997
1156
|
} else {
|
|
@@ -1000,7 +1159,23 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1000
1159
|
},
|
|
1001
1160
|
},
|
|
1002
1161
|
replyOptions: {
|
|
1003
|
-
|
|
1162
|
+
// 流式模式时禁用 block streaming
|
|
1163
|
+
disableBlockStreaming: !useStreaming,
|
|
1164
|
+
// 流式模式下注册 onPartialReply 回调,接收流式文本增量
|
|
1165
|
+
...(streamingController ? {
|
|
1166
|
+
onPartialReply: async (payload: { text?: string }) => {
|
|
1167
|
+
const textLen = payload.text?.length ?? 0;
|
|
1168
|
+
const preview = (payload.text ?? "").slice(0, 40).replace(/\n/g, "\\n");
|
|
1169
|
+
log?.debug?.(`[qqbot:${account.accountId}] onPartialReply called, textLen=${textLen}, phase=${streamingController!.currentPhase}, isTerminal=${streamingController!.isTerminalPhase}, preview="${preview}"`);
|
|
1170
|
+
try {
|
|
1171
|
+
await streamingController!.onPartialReply(payload);
|
|
1172
|
+
log?.debug?.(`[qqbot:${account.accountId}] onPartialReply done, phase=${streamingController!.currentPhase}`);
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
// StreamingController 内部已有重试,这里只打日志
|
|
1175
|
+
log?.error(`[qqbot:${account.accountId}] Streaming onPartialReply error: ${err}`);
|
|
1176
|
+
}
|
|
1177
|
+
},
|
|
1178
|
+
} : {}),
|
|
1004
1179
|
},
|
|
1005
1180
|
});
|
|
1006
1181
|
|
|
@@ -1026,13 +1201,48 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1026
1201
|
log?.error(`[qqbot:${account.accountId}] Dispatch completed with ${toolDeliverCount} tool deliver(s) but no block deliver, sending fallback`);
|
|
1027
1202
|
await sendToolFallback();
|
|
1028
1203
|
}
|
|
1204
|
+
// 销毁 debouncer,flush 剩余缓冲的文本
|
|
1205
|
+
if (debouncer) {
|
|
1206
|
+
await debouncer.dispose();
|
|
1207
|
+
debouncer = null;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// ============ 流式消息收尾 ============
|
|
1211
|
+
// dispatch 完成后,标记流式控制器已完成并触发 onIdle(发送终结分片)
|
|
1212
|
+
if (streamingController && !streamingController.isTerminalPhase) {
|
|
1213
|
+
try {
|
|
1214
|
+
streamingController.markFullyComplete();
|
|
1215
|
+
await streamingController.onIdle();
|
|
1216
|
+
log?.debug?.(`[qqbot:${account.accountId}] Streaming controller finalized`);
|
|
1217
|
+
} catch (err) {
|
|
1218
|
+
log?.error(`[qqbot:${account.accountId}] Streaming finalization error: ${err}`);
|
|
1219
|
+
// 尝试中止
|
|
1220
|
+
try { await streamingController.abortStreaming(); } catch { /* ignore */ }
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// ============ 流式降级到非流式 ============
|
|
1225
|
+
// 无需额外处理:如果流式 API 不可用(shouldFallbackToStatic),
|
|
1226
|
+
// deliver 回调中已自动跳过流式拦截,走普通消息发送逻辑。
|
|
1227
|
+
// (每次 deliver 收到的都是全量文本,不需要在 controller 内部保存累积文本)
|
|
1228
|
+
if (streamingController?.shouldFallbackToStatic) {
|
|
1229
|
+
log?.debug?.(`[qqbot:${account.accountId}] Streaming was degraded to static mode (no chunk sent successfully)`);
|
|
1230
|
+
}
|
|
1029
1231
|
}
|
|
1030
1232
|
} catch (err) {
|
|
1233
|
+
const errStr = String(err);
|
|
1031
1234
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
|
1235
|
+
// 兼容 openclaw 3.23+ runtime 模块解析失败:给用户发可操作的提示
|
|
1236
|
+
if (errStr.includes("Unable to resolve plugin runtime module") || errStr.includes("root-alias.cjs")) {
|
|
1237
|
+
try {
|
|
1238
|
+
await sendErrorMessage("⚠️ AI 服务暂时不可用:openclaw 框架运行时模块加载失败。\n\n请管理员执行:\nnpm install -g openclaw@latest\nopenclaw gateway restart\n\n斜杠命令(如 /bot-ping)不受影响。");
|
|
1239
|
+
} catch { /* best-effort */ }
|
|
1240
|
+
}
|
|
1032
1241
|
} finally {
|
|
1033
1242
|
// 无论成功/失败/超时,都停止输入状态续期
|
|
1034
1243
|
typing.keepAlive?.stop();
|
|
1035
1244
|
}
|
|
1245
|
+
}); // end runWithRequestContext
|
|
1036
1246
|
};
|
|
1037
1247
|
|
|
1038
1248
|
ws.on("open", () => {
|
|
@@ -1131,18 +1341,18 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1131
1341
|
|
|
1132
1342
|
// 仅 startGateway 后的首次 READY 才发送上线通知
|
|
1133
1343
|
// ws 断线重连(resume 失败后重新 Identify)产生的 READY 不发送
|
|
1134
|
-
if (!
|
|
1344
|
+
if (!_pendingFirstReady.has(account.accountId)) {
|
|
1135
1345
|
log?.info(`[qqbot:${account.accountId}] Skipping startup greeting (reconnect READY, not first startup)`);
|
|
1136
1346
|
} else {
|
|
1137
|
-
|
|
1347
|
+
_pendingFirstReady.delete(account.accountId);
|
|
1138
1348
|
sendStartupGreetings(adminCtx, "READY");
|
|
1139
1349
|
} // end isFirstReady
|
|
1140
1350
|
} else if (t === "RESUMED") {
|
|
1141
1351
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
|
1142
1352
|
onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
|
|
1143
1353
|
// RESUMED 也属于首次启动(gateway restart 通常走 resume)
|
|
1144
|
-
if (
|
|
1145
|
-
|
|
1354
|
+
if (_pendingFirstReady.has(account.accountId)) {
|
|
1355
|
+
_pendingFirstReady.delete(account.accountId);
|
|
1146
1356
|
sendStartupGreetings(adminCtx, "RESUMED");
|
|
1147
1357
|
}
|
|
1148
1358
|
// P1-2: 更新 Session 连接时间
|