@jerryliang122/openclaw-qqbot 1.0.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 (120) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +967 -0
  3. package/README.zh.md +790 -0
  4. package/dist/index.cjs +18072 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +1222 -0
  7. package/index.ts +92 -0
  8. package/openclaw.plugin.json +38 -0
  9. package/package.json +69 -0
  10. package/preload.cjs +19 -0
  11. package/scripts/link-sdk-core.cjs +268 -0
  12. package/scripts/proactive-api-server.ts +369 -0
  13. package/scripts/send-proactive.ts +293 -0
  14. package/scripts/test-sendmedia.ts +116 -0
  15. package/skills/qqbot-channel/SKILL.md +285 -0
  16. package/skills/qqbot-channel/references/api_references.md +521 -0
  17. package/skills/qqbot-remind/SKILL.md +159 -0
  18. package/skills/qqbot-upgrade/SKILL.md +56 -0
  19. package/src/adapter/contract.ts +63 -0
  20. package/src/adapter/lint.ts +144 -0
  21. package/src/adapter/media.ts +40 -0
  22. package/src/adapter/pairing.ts +95 -0
  23. package/src/adapter/resolve.ts +255 -0
  24. package/src/adapter/setup.ts +13 -0
  25. package/src/adapter/webhook.ts +248 -0
  26. package/src/adapter/workspace.ts +21 -0
  27. package/src/agent-prompt-adapter.ts +26 -0
  28. package/src/bot-instance.ts +60 -0
  29. package/src/channel.ts +230 -0
  30. package/src/commands/bot-approve.ts +143 -0
  31. package/src/commands/bot-clear-storage.ts +114 -0
  32. package/src/commands/bot-group-always.ts +62 -0
  33. package/src/commands/bot-group-info.ts +48 -0
  34. package/src/commands/bot-help.ts +40 -0
  35. package/src/commands/bot-logs.ts +248 -0
  36. package/src/commands/bot-me.ts +18 -0
  37. package/src/commands/bot-pairing.ts +50 -0
  38. package/src/commands/bot-ping.ts +33 -0
  39. package/src/commands/bot-streaming.ts +55 -0
  40. package/src/commands/bot-upgrade.ts +56 -0
  41. package/src/commands/bot-version.ts +41 -0
  42. package/src/commands/config-util.ts +96 -0
  43. package/src/commands/index.ts +51 -0
  44. package/src/config.ts +403 -0
  45. package/src/constants.ts +6 -0
  46. package/src/dispatch/body-assembler.ts +308 -0
  47. package/src/dispatch/ctx-builder.ts +127 -0
  48. package/src/dispatch/dispatch.ts +667 -0
  49. package/src/dispatch/envelope-builder.ts +112 -0
  50. package/src/dispatch/index.ts +2 -0
  51. package/src/features/approval-capability.ts +302 -0
  52. package/src/features/approval-helpers.ts +271 -0
  53. package/src/features/approval-utils.ts +21 -0
  54. package/src/features/command-panel.ts +301 -0
  55. package/src/features/credential-backup.ts +74 -0
  56. package/src/features/group-mode-store.ts +79 -0
  57. package/src/features/history-store.ts +75 -0
  58. package/src/features/msgid-cache.ts +55 -0
  59. package/src/features/outbound-echo-store.ts +46 -0
  60. package/src/features/proactive-budget.ts +57 -0
  61. package/src/features/proactive.ts +549 -0
  62. package/src/features/question-helpers.ts +771 -0
  63. package/src/features/quota-manager.ts +173 -0
  64. package/src/features/ref-index-store.ts +289 -0
  65. package/src/features/secret-input-store.ts +118 -0
  66. package/src/features/secret-store-cli.ts +324 -0
  67. package/src/features/typing-refresh.ts +51 -0
  68. package/src/features/update-checker.ts +166 -0
  69. package/src/gateway/event-handlers.ts +456 -0
  70. package/src/gateway/index.ts +3 -0
  71. package/src/gateway/lifecycle.ts +236 -0
  72. package/src/gateway/middleware-setup.ts +173 -0
  73. package/src/gateway/qqbot-gateway.ts +458 -0
  74. package/src/gateway-adapter.ts +44 -0
  75. package/src/heartbeat-adapter.ts +57 -0
  76. package/src/message-adapter.ts +40 -0
  77. package/src/messaging-adapter.ts +78 -0
  78. package/src/middleware/access-control.ts +125 -0
  79. package/src/middleware/attachment.ts +373 -0
  80. package/src/middleware/inbound-guard.ts +102 -0
  81. package/src/middleware/policy-injector.ts +71 -0
  82. package/src/middleware/secret-capture.ts +161 -0
  83. package/src/middleware/typing.ts +110 -0
  84. package/src/openclaw-plugin-sdk.d.ts +543 -0
  85. package/src/outbound/chunker.ts +80 -0
  86. package/src/outbound/debounce.ts +102 -0
  87. package/src/outbound/deliver-pipeline.ts +235 -0
  88. package/src/outbound/index.ts +3 -0
  89. package/src/outbound/local-file-router.ts +145 -0
  90. package/src/outbound/media-send.ts +408 -0
  91. package/src/outbound/outbound-service.ts +298 -0
  92. package/src/outbound/reply-limiter.ts +139 -0
  93. package/src/outbound/sanitize.ts +32 -0
  94. package/src/outbound/streaming-controller.ts +332 -0
  95. package/src/outbound/target.ts +109 -0
  96. package/src/outbound-adapter.ts +323 -0
  97. package/src/plugin-base.ts +42 -0
  98. package/src/request-context.ts +50 -0
  99. package/src/runtime.ts +42 -0
  100. package/src/setup/account-key.ts +41 -0
  101. package/src/setup/finalize.ts +110 -0
  102. package/src/setup/login.ts +197 -0
  103. package/src/setup/surface.ts +40 -0
  104. package/src/status-adapter.ts +56 -0
  105. package/src/tools/platform.ts +149 -0
  106. package/src/tools/remind.ts +308 -0
  107. package/src/tools/secret-input.ts +185 -0
  108. package/src/types-augment.d.ts +54 -0
  109. package/src/types-plugin.ts +82 -0
  110. package/src/types.ts +620 -0
  111. package/src/typing-lifecycle.ts +182 -0
  112. package/src/utils/mention.ts +52 -0
  113. package/src/utils/pkg-version.ts +23 -0
  114. package/src/utils/platform.ts +459 -0
  115. package/src/utils/plugin-logger.ts +104 -0
  116. package/src/utils/ssrf-guard.ts +132 -0
  117. package/src/utils/stt.ts +150 -0
  118. package/src/utils/voice-text.ts +61 -0
  119. package/tsconfig.json +17 -0
  120. package/tsup.config.ts +64 -0
@@ -0,0 +1,667 @@
1
+ /**
2
+ * 消息转发 — 入站消息 → OpenClaw AI
3
+ *
4
+ * 核心职责:
5
+ * 1. 从 SDK MiddlewareContext 构建 OpenClaw 标准信封
6
+ * 2. 通过 runtime-adapter 将消息交给 AI 处理
7
+ *
8
+ * 架构说明:
9
+ * - 所有 runtime.channel.* 访问均通过 runtime-adapter 隔离
10
+ * - log: 前缀由 PluginLogger + 框架自动注入,消息体不重复 accountId
11
+ */
12
+ import type { MiddlewareContext, QQBotInboundMessage } from '@tencent-connect/qqbot-nodejs';
13
+ import type { PluginRuntime } from 'openclaw/plugin-sdk';
14
+ import type { ResolvedQQBotAccount } from '../types.js';
15
+ import type { PluginLogger } from '../utils/plugin-logger.js';
16
+ import { buildEnvelope } from './envelope-builder.js';
17
+ import { assembleBody, type AssembledBody } from './body-assembler.js';
18
+ import { sendText, getGateway } from '../outbound/outbound-service.js';
19
+ import { sendMedia } from '../outbound/media-send.js';
20
+ import { deliverReply, type DeliverPayload, type DeliverInfo, type DeliverContext } from '../outbound/deliver-pipeline.js';
21
+ import { buildCtxPayload } from './ctx-builder.js';
22
+
23
+ import { DeliverDebouncer } from '../outbound/debounce.js';
24
+ import { StreamingController, shouldUseStreaming } from '../outbound/streaming-controller.js';
25
+ import { getAdapters } from '../adapter/resolve.js';
26
+ import { clearGroupHistory, trimGroupHistoryAfterLastBot } from '../features/history-store.js';
27
+ import {
28
+ isAskUserPayload,
29
+ isNonSingleAskUserPayload,
30
+ buildQuestionKeyboard,
31
+ buildMultiQuestionKeyboard,
32
+ formatMultiQuestionCard,
33
+ parseMultiQuestionPrompt,
34
+ registerPendingMultiQuestion,
35
+ getQuestionGatewayRuntime,
36
+ } from '../features/question-helpers.js';
37
+ import { tryGetBotForAccount } from '../bot-instance.js';
38
+ import { resolveGroupConfigFromAccount, resolveMentionPatterns } from '../config.js';
39
+ import { detectWasMentioned } from '../utils/mention.js';
40
+
41
+ /** 失败兜底文案(对齐 telegram:Something went wrong while processing your request.) */
42
+ const FAILURE_FALLBACK_TEXT = 'Something went wrong while processing your request. Please try again.';
43
+
44
+ /** 每进程只打一次「框架排队已接管线」INFO(排障锚点,确认 collect 真实生效) */
45
+ let frameworkQueueAnnounced = false;
46
+
47
+ /**
48
+ * 合并 AbortSignal(Node >= 20.3 使用 AbortSignal.any,低版本手工 fan-in)。
49
+ * turnAdoptionLifecycle 的 pre-adoption abort 需要与请求级 signal 共同生效。
50
+ */
51
+ function combineAbortSignals(
52
+ requestSignal: AbortSignal | undefined,
53
+ turnSignal: AbortSignal,
54
+ ): AbortSignal {
55
+ const any = (AbortSignal as unknown as { any?: (signals: AbortSignal[]) => AbortSignal }).any;
56
+ if (typeof any === 'function') {
57
+ return any.call(AbortSignal, requestSignal ? [requestSignal, turnSignal] : [turnSignal]);
58
+ }
59
+ if (!requestSignal) return turnSignal;
60
+
61
+ const controller = new AbortController();
62
+ const cleanup = () => {
63
+ requestSignal.removeEventListener('abort', onRequestAbort);
64
+ turnSignal.removeEventListener('abort', onTurnAbort);
65
+ };
66
+ const abortFrom = (signal: AbortSignal) => {
67
+ cleanup();
68
+ controller.abort(signal.reason);
69
+ };
70
+ const onRequestAbort = () => abortFrom(requestSignal);
71
+ const onTurnAbort = () => abortFrom(turnSignal);
72
+
73
+ if (requestSignal.aborted) abortFrom(requestSignal);
74
+ else if (turnSignal.aborted) abortFrom(turnSignal);
75
+ else {
76
+ requestSignal.addEventListener('abort', onRequestAbort, { once: true });
77
+ turnSignal.addEventListener('abort', onTurnAbort, { once: true });
78
+ }
79
+ return controller.signal;
80
+ }
81
+
82
+
83
+ /**
84
+ * 将经过中间件处理的入站消息转发给 OpenClaw AI
85
+ */
86
+ export async function dispatchToOpenClaw(
87
+ ctx: MiddlewareContext,
88
+ msg: QQBotInboundMessage,
89
+ account: ResolvedQQBotAccount,
90
+ runtime: PluginRuntime,
91
+ log?: PluginLogger,
92
+ ): Promise<void> {
93
+ const dlog = log?.child('dispatch');
94
+ const adapters = getAdapters(runtime, dlog);
95
+ const envelope = buildEnvelope(ctx, msg, account);
96
+
97
+ dlog?.debug(`received sender=${envelope.senderId} scope=${envelope.chatScope} msgId=${envelope.messageId}`);
98
+
99
+ if (!adapters.inboundRun || !adapters.dispatchReply) {
100
+ dlog?.error(`runtime adapter inboundRun/dispatchReply not available (openclaw=${adapters.version}, requires >=2026.9.2)`);
101
+ return;
102
+ }
103
+
104
+ const assembled: AssembledBody =
105
+ ((ctx.state as Record<string, unknown>).assembledBody as AssembledBody | undefined) ??
106
+ assembleBody(ctx, msg, account);
107
+
108
+ const cfg = adapters.getConfig?.() ?? {};
109
+
110
+ const isGroup = envelope.chatScope === 'group';
111
+
112
+ // 群聊/私聊差异化 sessionKey:
113
+ // - 群聊:group:{groupId}:coalescing 后缀(历史遗留命名,保留以延续存量群会话 lane;
114
+ // 排队/合并现已由框架 followup 队列按该 sessionKey 处理)
115
+ // - 私聊:保持原有格式,允许用户"插嘴"(新消息取消旧消息)
116
+ const peerId = envelope.chatScope === 'group'
117
+ ? (envelope.groupId ?? envelope.senderId)
118
+ : envelope.senderId;
119
+
120
+ const route = adapters.resolveAgentRoute?.({
121
+ cfg,
122
+ channel: 'qqbot',
123
+ accountId: account.accountId,
124
+ peer: {
125
+ kind: envelope.chatScope === 'group' ? 'group' : 'direct',
126
+ id: peerId,
127
+ },
128
+ }) ?? {
129
+ sessionKey: isGroup
130
+ ? `qqbot:${account.accountId}:group:${peerId}:coalescing`
131
+ : `qqbot:${account.accountId}:${peerId}`,
132
+ accountId: account.accountId
133
+ };
134
+
135
+ const qualifiedTarget = envelope.targetId;
136
+ const agentId = route.agentId ?? 'default';
137
+
138
+ // ── 群聊排队策略(排队/合并交给框架 followup 队列)──
139
+ // - enabled=true → collect:活动 turn 期间到达的消息排队并在其后合并批处理
140
+ // - enabled=false → followup:排队但不合并(尊重「关闭合并」的意图,
141
+ // 仍保证不打断活动 turn——远比框架默认 steer 插嘴安全)
142
+ const groupCfg = isGroup && envelope.groupId
143
+ ? resolveGroupConfigFromAccount(account, envelope.groupId)
144
+ : undefined;
145
+ const queueModeOverride = groupCfg
146
+ ? (groupCfg.coalesce.enabled ? 'collect' as const : 'followup' as const)
147
+ : undefined;
148
+
149
+ // ── room_event 分类(全量模式群的被动房间事件)──
150
+ // wasMentioned 三种唤醒方式:@(AT 事件 / mentions.is_you / 内容标记,由
151
+ // mentionGate 判定)、称呼(mentionPatterns,如「沈处」)、引用 bot 出站
152
+ // (mentionGate isImplicitMention)。均未命中且群开启 room_event 时,
153
+ // 该消息作为被动房间事件进框架:AI 只读上下文,最终文本不投递
154
+ // (message_tool_only),想发言走主动 message 工具;框架自动压 typing/
155
+ // 流式、排队不 steer。斜杠命令始终是显式用户意图 → user_request。
156
+ const mentionState = (ctx.state as { mention?: { wasMentioned?: boolean; implicit?: boolean } }).mention;
157
+ const nameMentioned = isGroup
158
+ ? detectWasMentioned({
159
+ eventType: (msg as { rawEventType?: string }).rawEventType,
160
+ mentions: (msg as { mentions?: Array<{ is_you?: boolean }> }).mentions,
161
+ content: msg.content,
162
+ mentionPatterns: resolveMentionPatterns(cfg, agentId),
163
+ })
164
+ : false;
165
+ const wasMentioned = !!(mentionState?.wasMentioned || mentionState?.implicit || nameMentioned);
166
+ const isSlash = /^\//.test(assembled.rawBody ?? '');
167
+ const inboundEventKind =
168
+ isGroup && groupCfg?.unmentionedInbound === 'room_event' && !wasMentioned && !isSlash
169
+ ? 'room_event' as const
170
+ : 'user_request' as const;
171
+
172
+ // 分类可观测性:room_event 事件触发一次推理 pass(成本排障命门),必须留痕;
173
+ // room_event 群里被唤醒为 user_request 时标注唤醒来源(@/称呼/引用),
174
+ // 便于观察 mentionPatterns 与引用唤醒的实际命中
175
+ if (inboundEventKind === 'room_event') {
176
+ dlog?.info(
177
+ `[room-event] passive room event group=${envelope.groupId} sender=${envelope.senderId} contentLen=${(msg.content ?? '').length}`,
178
+ );
179
+ } else if (isGroup && groupCfg?.unmentionedInbound === 'room_event' && !isSlash) {
180
+ const wakeSource = mentionState?.wasMentioned
181
+ ? 'mention'
182
+ : mentionState?.implicit
183
+ ? 'quote-bot'
184
+ : nameMentioned
185
+ ? 'name-pattern'
186
+ : 'unknown';
187
+ dlog?.info(`[wake] ${wakeSource} → user_request group=${envelope.groupId} sender=${envelope.senderId}`);
188
+ }
189
+
190
+ if (queueModeOverride && !frameworkQueueAnnounced) {
191
+ frameworkQueueAnnounced = true;
192
+ dlog?.info(`[queue] group turn queueing delegated to framework followup queue (mode=${queueModeOverride}); plugin coalescer bypassed`);
193
+ }
194
+ const storePath = adapters.resolveStorePath?.((cfg as any)?.session?.store, { agentId }) ?? '';
195
+
196
+ const ctxPayload = buildCtxPayload({ assembled, envelope, route, msg, ctx, adapters });
197
+ // room_event 分类标记:框架据此压制自动回复投递 / typing / steer
198
+ // (对齐 telegram 的 ctxPayload.InboundEventKind 约定)
199
+ ctxPayload.InboundEventKind = inboundEventKind;
200
+
201
+ // TTS 扩展点探测(runtime.tts / runtimeContexts),非核心 channel API
202
+ const ttsRuntime = (runtime as any)?.tts ?? (runtime as any)?.channel?.runtimeContexts?.get?.('tts'); // @adapter-bypass: TTS extension point probe
203
+
204
+ const debounceConfig = account.config?.deliverDebounce;
205
+ const debouncer = debounceConfig?.enabled !== false
206
+ ? new DeliverDebouncer(debounceConfig, async (targetId, mergedText) => {
207
+ const result = await sendText({ to: targetId, text: mergedText, accountId: account.accountId, replyToId: envelope.messageId, account });
208
+ trackOutbound(result, 'debounce');
209
+ })
210
+ : undefined;
211
+
212
+ const deliverCtx: DeliverContext = {
213
+ qualifiedTarget,
214
+ accountId: account.accountId,
215
+ replyToId: envelope.messageId,
216
+ chatScope: envelope.chatScope === 'group' ? 'group' : 'direct',
217
+ cfg,
218
+ debouncer: debouncer?.enabled ? debouncer : undefined,
219
+ sendText: (to, text) => sendText({ to, text, accountId: account.accountId, replyToId: envelope.messageId, account })
220
+ .then((result) => trackOutbound(result, 'deliverCtx.sendText')),
221
+ sendMedia: (to, source, opts) => sendMedia({
222
+ to,
223
+ source,
224
+ text: opts?.text ?? '',
225
+ replyToId: envelope.messageId,
226
+ accountId: account.accountId,
227
+ agentId: route.agentId,
228
+ log: deliverCtx.log,
229
+ }).then((result) => trackOutbound(result, 'deliverCtx.sendMedia')),
230
+ textToSpeech: ttsRuntime?.textToSpeech
231
+ ? (params) => ttsRuntime.textToSpeech(params)
232
+ : undefined,
233
+ audioFileToSilkBase64: ttsRuntime?.audioFileToSilkBase64
234
+ ? (audioPath: string) => ttsRuntime.audioFileToSilkBase64(audioPath)
235
+ : undefined,
236
+ log: log?.child('deliver'),
237
+ agentId: route.agentId ?? 'default',
238
+ };
239
+
240
+ const streamingEnabled = shouldUseStreaming(
241
+ account,
242
+ envelope.chatScope === 'group' ? 'group' : 'c2c',
243
+ );
244
+
245
+ const streamingController = streamingEnabled
246
+ ? createStreamingController(envelope, account, log?.child('streaming'))
247
+ : null;
248
+
249
+ if (streamingController) {
250
+ dlog?.debug(`streaming enabled for ${envelope.senderId}`);
251
+ }
252
+
253
+ const deliveredMediaUrls = new Set<string>();
254
+ const deliveredTexts = new Set<string>();
255
+ let deliverErrorCount = 0;
256
+ // 出站发送成功/失败计数:deliver-pipeline 对 sendText {error} 只记日志不抛错,
257
+ // 这里在 dispatch 拥有的发送闭包边界上统计,作为"用户是否收到可见回复"的判据。
258
+ let outboundSendOk = 0;
259
+ let outboundSendFail = 0;
260
+ const trackOutbound = <T extends { error?: string }>(result: T, via: string): T => {
261
+ if (result.error) {
262
+ outboundSendFail++;
263
+ dlog?.error(`outbound send failed via ${via}: ${String(result.error)}`);
264
+ } else {
265
+ outboundSendOk++;
266
+ }
267
+ return result;
268
+ };
269
+
270
+ /**
271
+ * deliver 回调(两个分支共用)。
272
+ *
273
+ * 失败语义(对齐 telegram):捕获后计数并记录日志,不中断后续 payload;
274
+ * dispatch 结束后若 (deliver 失败 || dispatch 抛错) 且用户未收到任何可见回复,
275
+ * 发送兜底消息,避免静默失败。
276
+ */
277
+ const deliverHandler = async (payload: DeliverPayload, info?: DeliverInfo): Promise<void> => {
278
+ try {
279
+ const kind = (info as any)?.kind as string | undefined;
280
+ const text = payload.text?.trim() ?? '';
281
+ const hasMedia = !!(payload.mediaUrl || payload.mediaUrls?.length);
282
+ dlog?.debug(`deliver kind=${kind ?? 'none'} textLen=${text.length} voice=${!!payload.audioAsVoice} media=${hasMedia}`);
283
+
284
+ // ── 0. ask_user 按钮投递(优先于所有其他处理)──
285
+ // 单问题单选场景:用 inline keyboard 替代纯文本
286
+ const payloadWithChannelData = payload as DeliverPayload & { channelData?: unknown };
287
+ if (isAskUserPayload(payloadWithChannelData as any) && text) {
288
+ const { questionId, optionValues } = (payloadWithChannelData as any).channelData.askUser;
289
+ const questionRuntime = await getQuestionGatewayRuntime();
290
+ {
291
+ const keyboard = buildQuestionKeyboard(questionId, optionValues);
292
+ const bot = tryGetBotForAccount(account.accountId);
293
+ if (bot) {
294
+ const replyTarget = {
295
+ scope: envelope.chatScope === 'group' ? 'group' as const : 'c2c' as const,
296
+ targetId: peerId,
297
+ };
298
+ try {
299
+ await bot.sendTextWithKeyboard(replyTarget, text, keyboard as never);
300
+ outboundSendOk++;
301
+ dlog?.debug(`[question] sent ask_user with keyboard questionId=${questionId} options=${optionValues.length}`);
302
+ return;
303
+ } catch (err) {
304
+ outboundSendFail++;
305
+ dlog?.error(`[question] sendTextWithKeyboard failed: ${err instanceof Error ? err.message : String(err)}`);
306
+ // fallback 到纯文本发送
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ // ── 0b. ask_user 多问题投递:每题一条带按钮的消息。
313
+ // 框架对多问题只投递纯文本(无结构化选项),这里从文本反解题目结构;
314
+ // 按钮点选 / isOther 题的文字回复在回调与入站侧缓冲,
315
+ // 集齐后合成一条"用户文本回复"走入站通道,由框架的文本应答
316
+ // 解析器 resolve 挂起的 ask_user ──
317
+ if (isNonSingleAskUserPayload(payloadWithChannelData as any) && text) {
318
+ const { questionId } = (payloadWithChannelData as any).channelData.askUser;
319
+ const questions = parseMultiQuestionPrompt(text);
320
+ if (questions) {
321
+ const bot = tryGetBotForAccount(account.accountId);
322
+ if (bot) {
323
+ const scope = envelope.chatScope === 'group' ? 'group' as const : 'c2c' as const;
324
+ const replyTarget = { scope, targetId: peerId };
325
+ // 先登记再发送,避免用户先点按钮时查无此单
326
+ registerPendingMultiQuestion(questionId, scope, peerId, questions);
327
+ try {
328
+ for (const [index, question] of questions.entries()) {
329
+ const cardText = formatMultiQuestionCard(question, index, questions.length);
330
+ const keyboard = buildMultiQuestionKeyboard(questionId, index, question);
331
+ await bot.sendTextWithKeyboard(replyTarget, cardText, keyboard as never);
332
+ outboundSendOk++;
333
+ }
334
+ dlog?.debug(`[question] sent multi-question ask_user questionId=${questionId} questions=${questions.length}`);
335
+ return;
336
+ } catch (err) {
337
+ outboundSendFail++;
338
+ dlog?.error(`[question] multi-question send failed: ${err instanceof Error ? err.message : String(err)}`);
339
+ // 已发出的卡片仍可点选;此处走纯文本兜底补全未送达部分
340
+ }
341
+ }
342
+ } else {
343
+ dlog?.debug(`[question] multi-question prompt unparseable questionId=${questionId}; plain text`);
344
+ }
345
+ }
346
+
347
+ // ── 1. block: 媒体/语音立即发送,文本留给流式 ──
348
+ // 注:static 模式已显式 disableBlockStreaming:true,kind:'block' 不会再触发;
349
+ // 此分支保留以兼容 stream 模式与未来变化。
350
+ if (kind === 'block') {
351
+ if (payload.audioAsVoice) {
352
+ await deliverReply(payload, info, deliverCtx);
353
+ } else {
354
+ await forwardMediaUrls(payload, deliverCtx, deliveredMediaUrls, dlog);
355
+ }
356
+ }
357
+
358
+ // ── 2. 流式路径:流式已启动且未降级 -> 跳过静态发送 ──
359
+ if (streamingController?.hasStarted && !streamingController?.shouldFallbackToStatic) {
360
+ if (streamingController.isStaticSendMode) {
361
+ // static 模式:flush 主路径由 onToolStart 驱动(工具开始前,绕开 SDK
362
+ // block streaming 的 coalescer,避免 minChars=800/idleMs=1000 buffer 延迟)。
363
+ // 这里仅兜底:deliver(kind:'tool') 时再 flush 一次(controller 内部去重,
364
+ // buffer 已空时 flushSegment 无副作用)。
365
+ if (kind === 'tool') {
366
+ await streamingController.flushSegment();
367
+ }
368
+ // static 模式不 finalize(finalize 会进入终态并造成后续丢失)
369
+ } else if (kind !== 'block') {
370
+ // stream 模式:tool/final 时收尾当前打字机流(原行为)
371
+ await streamingController.finalize();
372
+ }
373
+ if (!streamingController.shouldFallbackToStatic) return;
374
+ dlog?.warn(`streaming fallback to static`);
375
+ }
376
+
377
+ // ── 3. 文本去重:同文本已发过 -> 跳过 ──
378
+ if (kind === 'final' && !hasMedia && text && deliveredTexts.has(text)) {
379
+ return;
380
+ }
381
+
382
+ // ── 4. tool 媒体:立即转发(static 流式路径已在上方 flush 文本)──
383
+ if (kind === 'tool') {
384
+ await forwardMediaUrls(payload, deliverCtx, deliveredMediaUrls, dlog);
385
+ return;
386
+ }
387
+
388
+ // ── 5. 默认路径:过滤已发媒体 + 发送 ──
389
+ const filteredPayload = filterDeliveredMedia(payload, deliveredMediaUrls);
390
+ await deliverReply(filteredPayload, info, deliverCtx);
391
+ if (text) deliveredTexts.add(text);
392
+ } catch (err) {
393
+ deliverErrorCount++;
394
+ dlog?.error(`deliver error: ${err instanceof Error ? err.message : String(err)}`);
395
+ }
396
+ };
397
+
398
+ // Turn adoption lifecycle(群聊/私聊差异化):
399
+ // - 私聊:exclusive + abortSignal,新消息取消旧消息(用户可"插嘴")
400
+ // - 群聊:exclusive(框架 durable ingress 约定,对齐 telegram;cancel-only 是
401
+ // gateway chat.send 的取消身份语义,不适用于通道入站)且不传 abortSignal——
402
+ // 新消息由框架 followup 队列排队,不打断正在处理的 turn
403
+ const turnAbort = new AbortController();
404
+ const admission = 'exclusive' as const;
405
+ // 仅私聊允许插嘴取消;群聊不打断(排队由框架 followup 队列处理)
406
+ const interruptible = !isGroup;
407
+
408
+ const turnAdoptionLifecycle = {
409
+ admission,
410
+ abortSignal: interruptible ? turnAbort.signal : undefined,
411
+ onAdopted: () => {
412
+ dlog?.debug(`turn adopted (exclusive, ${isGroup ? 'group' : 'c2c'}) sessionKey=${route.sessionKey}`);
413
+ },
414
+ onDeferred: () => {
415
+ dlog?.debug(`turn deferred behind active turn sessionKey=${route.sessionKey}`);
416
+ },
417
+ onAbandoned: () => {
418
+ if (interruptible) {
419
+ dlog?.info(`turn abandoned (superseded) — aborting sessionKey=${route.sessionKey}`);
420
+ turnAbort.abort();
421
+ } else {
422
+ dlog?.info(`group turn abandoned without owning reply lane sessionKey=${route.sessionKey}`);
423
+ }
424
+ },
425
+ };
426
+ const combinedAbortSignal = interruptible
427
+ ? combineAbortSignals(ctx.signal, turnAbort.signal)
428
+ : ctx.signal;
429
+
430
+ let dispatchError: unknown;
431
+ const hadDispatchError = () => dispatchError !== undefined;
432
+
433
+ try {
434
+ await adapters.inboundRun!({
435
+ channel: 'qqbot',
436
+ accountId: route.accountId,
437
+ raw: envelope,
438
+ adapter: {
439
+ ingest: (raw: any) => ({
440
+ id: envelope.messageId,
441
+ rawText: assembled.rawBody,
442
+ textForAgent: assembled.agentBody,
443
+ textForCommands: assembled.rawBody,
444
+ raw,
445
+ }),
446
+ resolveTurn: (_input: unknown, _eventClass: unknown, _preflight: unknown) => ({
447
+ channel: 'qqbot',
448
+ accountId: route.accountId,
449
+ routeSessionKey: route.sessionKey,
450
+ storePath,
451
+ ctxPayload,
452
+ recordInboundSession: adapters.recordInboundSession,
453
+ record: {
454
+ onRecordError: (err: unknown) => {
455
+ dlog?.error(`Session record error: ${err}`);
456
+ },
457
+ },
458
+ runDispatchLifecycle: {
459
+ // 同一 lifecycle 对象必须同时出现在 runDispatchLifecycle 与
460
+ // replyOptions.turnAdoptionLifecycle(框架校验所有权一致性)。
461
+ turnAdoptionLifecycle,
462
+ onDispatchSkipped: (reason: string) => {
463
+ dlog?.info(`dispatch skipped reason=${reason} sessionKey=${route.sessionKey}`);
464
+ },
465
+ },
466
+ runDispatch: () => {
467
+ return adapters.dispatchReply!({
468
+ ctx: ctxPayload,
469
+ cfg,
470
+ dispatcherOptions: {
471
+ deliver: deliverHandler,
472
+ },
473
+ replyOptions: {
474
+ abortSignal: combinedAbortSignal,
475
+ runId: envelope.messageId,
476
+ turnAdoptionLifecycle,
477
+ ...(queueModeOverride ? { queueModeOverride } : {}),
478
+ ...(inboundEventKind === 'room_event' ? { sourceReplyDeliveryMode: 'message_tool_only' as const } : {}),
479
+ ...(streamingController?.isStaticSendMode
480
+ ? {
481
+ // 对齐 telegram 模式一:显式关掉 SDK block streaming,绕开 coalescer
482
+ // (minChars=800/idleMs=1000 的 buffer 会造成文本延迟)。
483
+ // 文本由 onPartialReply 累积,边界由 onToolStart 自己监听 flush。
484
+ disableBlockStreaming: true,
485
+ // 让 onToolStart 在 verbose 关闭时也能触发
486
+ // (默认受 requiresToolSummaryVisibility 门控,verbose off 时不触发)
487
+ allowToolLifecycleWhenProgressHidden: true,
488
+ // 工具【开始执行前】触发:把已累积的上一段文本立即发出
489
+ // (不等工具执行完,对齐 telegram prepareAnswerLaneForToolProgress)
490
+ onToolStart: async () => { await streamingController.flushSegment(); },
491
+ }
492
+ : {}),
493
+ ...(streamingController
494
+ ? {
495
+ onPartialReply: async (p: { text?: string }) => {
496
+ if (p.text) await streamingController.onPartialReply(p.text);
497
+ },
498
+ // 兜底:block 信号未覆盖的边界(如部分 provider 不发 text_end)
499
+ // 仍由 onAssistantMessageStart 触发分段。stream 模式不传,保持原行为。
500
+ onAssistantMessageStart: streamingController.isStaticSendMode
501
+ ? async () => { await streamingController.flushSegment(); }
502
+ : undefined,
503
+ }
504
+ : {}),
505
+ },
506
+ });
507
+ },
508
+ }),
509
+ },
510
+ });
511
+ } catch (err) {
512
+ dispatchError = err;
513
+ dlog?.error(`dispatch failed: ${err instanceof Error ? err.message : String(err)}`);
514
+ }
515
+
516
+ dlog?.debug(`dispatch completed sessionKey=${route.sessionKey}`);
517
+
518
+ // 群消息回复后处理历史缓存:
519
+ // - clear(默认):整清,下次 @ 时组包"自上次回复以来"的窗口
520
+ // - rolling:裁剪到最后一条 bot 出站之后(bot 发言也计入历史,
521
+ // AI 下次可看到自己上次说到哪;对齐 telegram selectAfterLastSelf)
522
+ if (envelope.chatScope === 'group' && envelope.groupId) {
523
+ const groupCfg = resolveGroupConfigFromAccount(account, envelope.groupId);
524
+ if (groupCfg.historyMode === 'rolling') {
525
+ const kept = trimGroupHistoryAfterLastBot(account.accountId, envelope.groupId, groupCfg.historyLimit);
526
+ dlog?.debug(`[history] rolling trim group=${envelope.groupId} kept=${kept}`);
527
+ } else {
528
+ clearGroupHistory(account.accountId, envelope.groupId);
529
+ }
530
+ } else if (envelope.chatScope === 'group') {
531
+ clearGroupHistory(account.accountId, envelope.senderId);
532
+ }
533
+
534
+ if (streamingController && !streamingController.isTerminal) {
535
+ await streamingController.finalize();
536
+ }
537
+
538
+ if (debouncer) {
539
+ await debouncer.flushAll();
540
+ }
541
+
542
+ // 失败兜底(对齐 telegram):dispatch 抛错、deliver 抛错或底层发送失败,
543
+ // 且用户未收到任何可见回复时,发送兜底消息而非静默失败。
544
+ if ((hadDispatchError() || deliverErrorCount > 0 || outboundSendFail > 0) && !turnAbort.signal.aborted) {
545
+ const streamedVisible = !!streamingController
546
+ && streamingController.currentPhase !== 'failed'
547
+ && (streamingController.currentPhase === 'done' || streamingController.hasSentChunks);
548
+ const deliveredVisible =
549
+ streamedVisible || outboundSendOk > 0 || deliveredMediaUrls.size > 0;
550
+ if (!deliveredVisible) {
551
+ dlog?.warn(
552
+ `sending failure fallback (deliverErrors=${deliverErrorCount} sendFails=${outboundSendFail} dispatchError=${hadDispatchError()}) to ${qualifiedTarget}`,
553
+ );
554
+ try {
555
+ const result = await sendText({
556
+ to: qualifiedTarget,
557
+ text: FAILURE_FALLBACK_TEXT,
558
+ accountId: account.accountId,
559
+ replyToId: envelope.messageId,
560
+ account,
561
+ });
562
+ if (result.error) {
563
+ dlog?.error(`failure fallback sendText failed: ${result.error}`);
564
+ }
565
+ } catch (err) {
566
+ dlog?.error(`failure fallback failed: ${err instanceof Error ? err.message : String(err)}`);
567
+ }
568
+ }
569
+ }
570
+
571
+ // 保持原有错误传播语义:错误上报给 event-handlers 记录日志
572
+ if (hadDispatchError()) {
573
+ throw dispatchError;
574
+ }
575
+ }
576
+
577
+ function createStreamingController(
578
+ envelope: ReturnType<typeof buildEnvelope>,
579
+ account: ResolvedQQBotAccount,
580
+ log?: PluginLogger,
581
+ ): StreamingController | null {
582
+ const gw = getGateway(account.accountId);
583
+ if (!gw) {
584
+ log?.error(`cannot enable streaming — gateway not running`);
585
+ return null;
586
+ }
587
+
588
+ // sendMode: 默认 'stream'(QQ 流式打印机),可选 'static'(普通 sendText 收尾)
589
+ const streamingCfg = account.config?.streaming as
590
+ | { sendMode?: 'stream' | 'static' }
591
+ | undefined;
592
+ const sendMode = streamingCfg?.sendMode === 'static' ? 'static' : 'stream';
593
+
594
+ // static 模式:finalize 收尾时用一条普通 sendText 发完整文本
595
+ const sendStatic = sendMode === 'static'
596
+ ? async (fullText: string) => {
597
+ const result = await sendText({
598
+ to: envelope.senderId,
599
+ text: fullText,
600
+ accountId: account.accountId,
601
+ replyToId: envelope.messageId,
602
+ account,
603
+ });
604
+ if (result.error) {
605
+ log?.error(`static sendText failed: ${result.error}`);
606
+ }
607
+ }
608
+ : undefined;
609
+
610
+ return new StreamingController({
611
+ gateway: gw,
612
+ target: {
613
+ scope: 'c2c',
614
+ targetId: envelope.senderId,
615
+ msgId: envelope.messageId,
616
+ },
617
+ accountId: account.accountId,
618
+ replyToId: envelope.messageId,
619
+ log,
620
+ sendMode,
621
+ sendStatic,
622
+ });
623
+ }
624
+
625
+ // ── 辅助函数 ──
626
+
627
+ /** 提取 payload 中的媒体 URL 并逐个发送(去重) */
628
+ async function forwardMediaUrls(
629
+ payload: DeliverPayload,
630
+ ctx: DeliverContext,
631
+ delivered: Set<string>,
632
+ log?: PluginLogger,
633
+ ): Promise<void> {
634
+ const urls: string[] = [];
635
+ if (payload.mediaUrls?.length) urls.push(...payload.mediaUrls);
636
+ if (payload.mediaUrl && !urls.includes(payload.mediaUrl)) urls.push(payload.mediaUrl);
637
+ const newUrls = urls.filter((u) => !delivered.has(u));
638
+ for (const url of newUrls) {
639
+ try {
640
+ await sendMedia({
641
+ to: ctx.qualifiedTarget,
642
+ source: url,
643
+ text: '',
644
+ replyToId: ctx.replyToId,
645
+ accountId: ctx.accountId,
646
+ log: ctx.log,
647
+ agentId: ctx.agentId,
648
+ });
649
+ delivered.add(url);
650
+ } catch (err) {
651
+ log?.error(`media forward failed: ${err instanceof Error ? err.message : String(err)}`);
652
+ }
653
+ }
654
+ }
655
+
656
+ /** 过滤已发送的媒体 URL */
657
+ function filterDeliveredMedia(
658
+ payload: DeliverPayload,
659
+ delivered: Set<string>,
660
+ ): DeliverPayload {
661
+ if (delivered.size === 0) return payload;
662
+ return {
663
+ ...payload,
664
+ mediaUrl: payload.mediaUrl && !delivered.has(payload.mediaUrl) ? payload.mediaUrl : undefined,
665
+ mediaUrls: payload.mediaUrls?.filter((u) => !delivered.has(u)),
666
+ };
667
+ }