@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.
Files changed (89) hide show
  1. package/README.md +2 -15
  2. package/README.zh.md +3 -16
  3. package/dist/src/admin-resolver.d.ts +12 -6
  4. package/dist/src/admin-resolver.js +69 -34
  5. package/dist/src/api.d.ts +105 -1
  6. package/dist/src/api.js +164 -15
  7. package/dist/src/channel.js +13 -0
  8. package/dist/src/config.js +3 -10
  9. package/dist/src/deliver-debounce.d.ts +74 -0
  10. package/dist/src/deliver-debounce.js +174 -0
  11. package/dist/src/gateway.js +450 -248
  12. package/dist/src/image-server.d.ts +27 -8
  13. package/dist/src/image-server.js +179 -71
  14. package/dist/src/inbound-attachments.d.ts +3 -1
  15. package/dist/src/inbound-attachments.js +28 -14
  16. package/dist/src/outbound-deliver.js +77 -148
  17. package/dist/src/outbound.d.ts +6 -4
  18. package/dist/src/outbound.js +266 -442
  19. package/dist/src/reply-dispatcher.js +4 -4
  20. package/dist/src/request-context.d.ts +18 -0
  21. package/dist/src/request-context.js +30 -0
  22. package/dist/src/slash-commands.js +277 -32
  23. package/dist/src/startup-greeting.d.ts +5 -5
  24. package/dist/src/startup-greeting.js +32 -13
  25. package/dist/src/streaming.d.ts +244 -0
  26. package/dist/src/streaming.js +907 -0
  27. package/dist/src/tools/remind.js +11 -10
  28. package/dist/src/types.d.ts +101 -0
  29. package/dist/src/types.js +17 -1
  30. package/dist/src/update-checker.js +2 -8
  31. package/dist/src/utils/audio-convert.d.ts +9 -0
  32. package/dist/src/utils/audio-convert.js +51 -0
  33. package/dist/src/utils/chunked-upload.d.ts +59 -0
  34. package/dist/src/utils/chunked-upload.js +289 -0
  35. package/dist/src/utils/file-utils.d.ts +7 -1
  36. package/dist/src/utils/file-utils.js +24 -2
  37. package/dist/src/utils/media-send.d.ts +147 -0
  38. package/dist/src/utils/media-send.js +434 -0
  39. package/dist/src/utils/pkg-version.d.ts +5 -0
  40. package/dist/src/utils/pkg-version.js +51 -0
  41. package/dist/src/utils/ssrf-guard.d.ts +25 -0
  42. package/dist/src/utils/ssrf-guard.js +91 -0
  43. package/node_modules/ws/index.js +15 -6
  44. package/node_modules/ws/lib/permessage-deflate.js +6 -6
  45. package/node_modules/ws/lib/websocket-server.js +5 -5
  46. package/node_modules/ws/lib/websocket.js +6 -6
  47. package/node_modules/ws/package.json +4 -3
  48. package/node_modules/ws/wrapper.mjs +14 -1
  49. package/openclaw.plugin.json +1 -0
  50. package/package.json +11 -22
  51. package/scripts/postinstall-link-sdk.js +113 -0
  52. package/scripts/upgrade-via-npm.ps1 +161 -6
  53. package/scripts/upgrade-via-npm.sh +311 -104
  54. package/scripts/upgrade-via-source.sh +117 -0
  55. package/skills/qqbot-media/SKILL.md +9 -5
  56. package/skills/qqbot-remind/SKILL.md +3 -3
  57. package/src/admin-resolver.ts +76 -35
  58. package/src/api.ts +284 -12
  59. package/src/channel.ts +12 -0
  60. package/src/config.ts +3 -10
  61. package/src/deliver-debounce.ts +229 -0
  62. package/src/gateway.ts +277 -67
  63. package/src/image-server.ts +213 -77
  64. package/src/inbound-attachments.ts +32 -15
  65. package/src/outbound-deliver.ts +77 -157
  66. package/src/outbound.ts +304 -451
  67. package/src/reply-dispatcher.ts +4 -4
  68. package/src/request-context.ts +39 -0
  69. package/src/slash-commands.ts +303 -33
  70. package/src/startup-greeting.ts +35 -13
  71. package/src/streaming.ts +1096 -0
  72. package/src/tools/remind.ts +15 -11
  73. package/src/types.ts +111 -0
  74. package/src/update-checker.ts +2 -7
  75. package/src/utils/audio-convert.ts +56 -0
  76. package/src/utils/chunked-upload.ts +419 -0
  77. package/src/utils/file-utils.ts +28 -2
  78. package/src/utils/media-send.ts +563 -0
  79. package/src/utils/pkg-version.ts +54 -0
  80. package/src/utils/ssrf-guard.ts +102 -0
  81. package/clawdbot.plugin.json +0 -16
  82. package/dist/src/user-messages.d.ts +0 -8
  83. package/dist/src/user-messages.js +0 -8
  84. package/moltbot.plugin.json +0 -16
  85. package/scripts/upgrade-via-alt-pkg.sh +0 -307
  86. package/src/bot-logs-2026-03-21T11-21-47(2).txt +0 -46
  87. package/src/gateway.log +0 -43
  88. package/src/openclaw-2026-03-21.log +0 -3729
  89. package/src/user-messages.ts +0 -7
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
- // 模块级变量:进程生命周期内只有首次为 true
93
+ // 模块级变量:per-account 首次 READY 跟踪
90
94
  // 区分 gateway restart(进程重启)和 health-monitor 断线重连
91
- let isFirstReadyGlobal = true;
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
- // 使用模块级 isFirstReadyGlobal,确保只有进程级重启才发送问候语
190
- // health-monitor 重连不会重新初始化为 true
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, { accountId: account.accountId, cfg, log });
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
- // 这里只补充 QQBot 独有的:投递地址(cron skill 需要)
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
- // 静态指引作为 systemPrompts 的首项注入
642
- systemPrompts.unshift(staticInstruction);
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
- log?.info(`[qqbot:${account.accountId}] Block already sent, immediately forwarding ${toolMediaUrls.length} tool media URL(s)`);
855
- const urlsToSend = [...toolMediaUrls];
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
- const quoteRef = event.msgIdx;
929
- let quoteRefUsed = false;
930
- const consumeQuoteRef = (): string | undefined => {
931
- if (quoteRef && !quoteRefUsed) {
932
- quoteRefUsed = true;
933
- return quoteRef;
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
- let replyText = payload.text ?? "";
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 deliverEvent: DeliverEventContext = {
942
- type: event.type,
943
- senderId: event.senderId,
944
- messageId: event.messageId,
945
- channelId: event.channelId,
946
- groupOpenid: event.groupOpenid,
947
- msgIdx: event.msgIdx,
948
- };
949
- const deliverActx: DeliverAccountContext = { account, qualifiedTarget, log };
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
- const mediaResult = await parseAndSendMediaTags(
952
- replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef,
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
- return;
961
- }
962
- replyText = mediaResult.normalizedText;
1084
+ const handled = await handleStructuredPayload(replyCtx, replyText, recordOutboundActivity);
1085
+ if (handled) return;
963
1086
 
964
- // ============ 结构化载荷检测与分发 ============
965
- const recordOutboundActivity = () => pluginRuntime.channel.activity.record({
966
- channel: "qqbot",
967
- accountId: account.accountId,
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
- await sendPlainReply(
975
- payload, replyText, deliverEvent, deliverActx,
976
- sendWithRetry, consumeQuoteRef, toolMediaUrls,
977
- );
1092
+ await sendPlainReply(
1093
+ deliverPayload, replyText, deliverEvent, deliverActx,
1094
+ sendWithRetry, consumeQuoteRef, toolMediaUrls,
1095
+ );
978
1096
 
979
- pluginRuntime.channel.activity.record({
980
- channel: "qqbot",
981
- accountId: account.accountId,
982
- direction: "outbound",
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
- disableBlockStreaming: true,
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 (!isFirstReadyGlobal) {
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
- isFirstReadyGlobal = false;
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 (isFirstReadyGlobal) {
1145
- isFirstReadyGlobal = false;
1354
+ if (_pendingFirstReady.has(account.accountId)) {
1355
+ _pendingFirstReady.delete(account.accountId);
1146
1356
  sendStartupGreetings(adminCtx, "RESUMED");
1147
1357
  }
1148
1358
  // P1-2: 更新 Session 连接时间