@qwen-code/channel-qqbot 0.21.0-nightly.20260729.0c0ca5fed

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.
@@ -0,0 +1,2153 @@
1
+ /**
2
+ * QQ Bot channel adapter for Qwen Code.
3
+ *
4
+ * Connects QQ Bot via official QQ Bot WebSocket API.
5
+ * Extends ChannelBase for streaming, access control, and session routing.
6
+ * Supports QR code login, credential persistence, C2C and group chat.
7
+ *
8
+ * Cross-server context continuation: persists SessionRouter mappings and
9
+ * QQ-specific routing state (chatTypeMap, replyMsgId, msgSeqMap) to disk,
10
+ * restoring them on reconnect so conversations survive daemon restarts.
11
+ *
12
+ * @see https://bot.q.qq.com/wiki/develop/api-v2/
13
+ */
14
+ import { ChannelBase, SessionRouter, getGlobalQwenDir, sanitizeSenderName, sanitizePromptText, sanitizeLogText, } from '@qwen-code/channel-base';
15
+ import WebSocket from 'ws';
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { OpCode, Intent } from './types.js';
19
+ import { getCredsFilePath, loadCredentials, saveCredentials, } from './accounts.js';
20
+ import { qrCodeLogin } from './login.js';
21
+ import { fetchAccessToken, fetchGatewayUrl, getApiBase, sendQQMessage, } from './api.js';
22
+ export class DeliveryError extends Error {
23
+ code;
24
+ constructor(code, message) {
25
+ super(message);
26
+ this.code = code;
27
+ this.name = 'DeliveryError';
28
+ }
29
+ }
30
+ /** Validate chatId to prevent SSRF when constructing URLs. */
31
+ export function isValidChatId(id) {
32
+ return /^[A-Za-z0-9_-]+$/.test(id) && id.length <= 128;
33
+ }
34
+ export class QQChannel extends ChannelBase {
35
+ ws = null;
36
+ accessToken = '';
37
+ tokenExpiresAt = 0;
38
+ tokenRefreshTimer = null;
39
+ heartbeatTimer = null;
40
+ heartbeatInterval = 45000;
41
+ seq = 0;
42
+ reconnectAttempts = 0;
43
+ maxReconnectAttempts;
44
+ /** QQ Bot session_id from READY, used for RESUME on reconnect. */
45
+ sessionId = '';
46
+ /** Whether this connection attempt should try RESUME first. */
47
+ tryResume = false;
48
+ qqConfig;
49
+ /** Set when server sends RECONNECT opcode — close handler uses this to force reconnect. */
50
+ serverRequestedReconnect = false;
51
+ /** Pending connect promise reject — called when WebSocket closes before READY. */
52
+ connectReject = null;
53
+ /** Set to true when channel is disconnected — prevents orphaned connections. */
54
+ disposed = false;
55
+ /** Deduplicate inbound messages on reconnect replay (messageId → timestamp). */
56
+ seenMessages = new Map();
57
+ /** Cleanup timer for seenMessages TTL eviction. */
58
+ seenCleanupTimer = null;
59
+ /** Timestamp of last received HEARTBEAT_ACK, for zombie-connection detection. */
60
+ lastHeartbeatAck = 0;
61
+ /** Debounce timer for saveQQState to avoid blocking event loop. */
62
+ saveTimer = null;
63
+ /** beforeExit hook to flush state when the event loop drains naturally. Does NOT fire for SIGKILL, OOM kills, or uncaughtException. */
64
+ beforeExitHook = null;
65
+ /** Timer for reconnectWithRetry fallback (unref'd so it doesn't block exit). */
66
+ reconnectTimer = null;
67
+ /** 30s READY timeout to prevent hanging on gateway without response. */
68
+ readyTimeout = null;
69
+ /** Guard against parallel reconnectWithRetry chains from stale close events. */
70
+ isReconnecting = false;
71
+ /** Track whether a chatId is a group or C2C for correct API routing. */
72
+ chatTypeMap = new Map();
73
+ /** Track the latest user messageId per chatId for proper reply (msg_id). */
74
+ replyMsgId = new Map();
75
+ /** msg_seq counter per user messageId, for multi-block streaming. */
76
+ msgSeqMap = new Map();
77
+ /** Periodic cleanup timer for expired replyMsgId entries. */
78
+ replyMsgIdCleanupTimer = null;
79
+ /** 5-minute TTL for replyMsgId entries and seenMessages dedup. */
80
+ static REPLY_MSG_ID_TTL_MS = 300_000;
81
+ /** Idle-flush timeout: buffer is sent after this many ms of silence. */
82
+ static IDLE_FLUSH_MS = 2000;
83
+ /** Max consecutive send failures before the stream is abandoned. */
84
+ maxFlushRetries;
85
+ /** Retry delay for subsequent attempts (backoff beyond first retry). */
86
+ static IDLE_FLUSH_BACKOFF_MS = 4000;
87
+ /** Max buffer length before forcing an immediate flush. */
88
+ static MAX_BUFFER_LENGTH = 4096;
89
+ // ── Group / cron fields ────────────────────────────────────────
90
+ /** Per-group bot OPENID map for multi-group support. */
91
+ botOpenIdByGroup = new Map();
92
+ /** Guard: set to true after first READY + session restore completes. */
93
+ _ready = false;
94
+ /** Whether this process has never received READY (cold start). */
95
+ coldStart = true;
96
+ /** Track per-group active message permission. */
97
+ groupActiveMsgEnabled = new Map();
98
+ /** Lazy cache for compiled keyword trigger RegExp patterns.
99
+ * Built lazily on first access; never invalidated — keywordTriggers is not modified at runtime. */
100
+ _keywordTriggerCache = null;
101
+ /** Rate-limit timestamps for keyword non-match log entries (chatId → last log ms). */
102
+ _lastKeywordNoMatchLog = new Map();
103
+ /** Accumulation buffer for cron/non-prompt textChunk events. */
104
+ cronBuffer = new Map();
105
+ /** Named handler for permanent textChunk listener (cron/non-prompt). */
106
+ _cronTextHandler = null;
107
+ /** Gate: depth counter for cron-scheduled message flows. >0 means in-flow.
108
+ Prevents phantom cronBuffer entries when textChunk fires during normal
109
+ bridge.prompt() calls (ChannelBase has its own listener there).
110
+ Using a counter instead of a boolean supports concurrent cron flows. */
111
+ _inCronFlow = 0;
112
+ cronTextHandlerAttached = false;
113
+ /** Path to persisted QQ routing state: chatTypeMap, replyMsgId, msgSeqMap. */
114
+ /**
115
+ * Streaming state machine with per-session buffers.
116
+ *
117
+ * Three states for each session:
118
+ * active — accumulating chunks in buffer (onResponseChunk extends timer)
119
+ * flushing — sendMessage() is in-flight (prevents parallel sends)
120
+ * idle — waiting for next chunk (timer counting down to idleFlush)
121
+ *
122
+ * Transitions:
123
+ * active → flushing: idleFlush timer fires, or onToolCall cancels timer
124
+ * flushing → idle: send settles, idle timer restarts on retry
125
+ * any → done: onResponseComplete sends remaining content
126
+ *
127
+ * Guards:
128
+ * - flushingSessions prevents concurrent sends per session
129
+ * - pendingStreamDelete defers cleanup until in-flight send resolves
130
+ * - flushedSessions tracks already-sent sessions to skip final fullText
131
+ */
132
+ // ── Streaming state ───────────────────────────────────────────
133
+ streamState = new Map();
134
+ flushingSessions = new Set();
135
+ pendingStreamDelete = new Set();
136
+ _reconnectId = 0;
137
+ blockStreaming = false;
138
+ flushedSessions = new Set();
139
+ qqStatePath;
140
+ /**
141
+ * Path to the global sessions.json managed by start.ts.
142
+ * start.ts deletes it on shutdown, so we back it up.
143
+ */
144
+ globalSessionsPath;
145
+ /** Backup of sessions.json so conversations survive daemon restarts. */
146
+ sessionsBackupPath;
147
+ constructor(name, config, bridge, options) {
148
+ const safeName = name.replace(/[^A-Za-z0-9_-]/g, '_');
149
+ const stateDir = join(getGlobalQwenDir(), 'channels');
150
+ mkdirSync(stateDir, { recursive: true });
151
+ const sessionsPath = join(stateDir, `${safeName}-sessions.json`);
152
+ // groupAllPolicy 'keyword' or 'all' requires 'single' session scope
153
+ // because all group messages share one conversation context.
154
+ const qqCfg = config;
155
+ if ((qqCfg.groupAllPolicy === 'keyword' || qqCfg.groupAllPolicy === 'all') &&
156
+ config.sessionScope !== 'single') {
157
+ const originalScope = config.sessionScope;
158
+ process.stderr.write(`[QQ:${name}] WARNING: groupAllPolicy is '${qqCfg.groupAllPolicy}' but sessionScope is '${originalScope}' (not 'single'). Forcing sessionScope to 'single' to ensure shared group context.\n`);
159
+ config = { ...config, sessionScope: 'single' };
160
+ }
161
+ const router = options?.router ??
162
+ new SessionRouter(bridge, config.cwd, config.sessionScope, sessionsPath);
163
+ super(name, config, bridge, {
164
+ ...options,
165
+ router,
166
+ registerBridgeEvents: options?.registerBridgeEvents ?? !options?.router,
167
+ });
168
+ this.qqConfig = config;
169
+ this.maxReconnectAttempts = this.qqConfig.maxReconnectAttempts ?? 20;
170
+ this.maxFlushRetries = this.qqConfig.maxFlushRetries ?? 3;
171
+ const raw = this.qqConfig.bufferFlushLength;
172
+ if (raw !== undefined &&
173
+ (!Number.isInteger(raw) || raw <= 0 || raw > QQChannel.MAX_BUFFER_LENGTH)) {
174
+ process.stderr.write(`[QQ:${this.name}] WARNING: invalid bufferFlushLength=${raw}, using default ${QQChannel.MAX_BUFFER_LENGTH}\n`);
175
+ this.qqConfig.bufferFlushLength = QQChannel.MAX_BUFFER_LENGTH;
176
+ }
177
+ this.blockStreaming = this.config.blockStreaming === 'on';
178
+ this.qqStatePath = join(stateDir, `${safeName}-state.json`);
179
+ // In standalone mode (no external router), use the per-channel
180
+ // sessions path so the channel owns its own session file.
181
+ this.globalSessionsPath = options?.router
182
+ ? join(stateDir, 'sessions.json')
183
+ : sessionsPath;
184
+ this.sessionsBackupPath = join(stateDir, `${safeName}-sessions-backup.json`);
185
+ // Permanent textChunk listener for cron/non-prompt messages.
186
+ // ChannelBase's prompt-path textChunk listener is only alive during
187
+ // bridge.prompt(). Cron messages bypass prompt() so their textChunk
188
+ // events arrive without a listener. This permanent listener catches them.
189
+ if (this.qqConfig['cron-msg-experimental']) {
190
+ this._cronTextHandler = (sid, t) => this.handleCronTextChunk(sid, t);
191
+ this.attachCronHandler();
192
+ }
193
+ }
194
+ handleCronTextChunk(sessionId, text) {
195
+ const wasInCronFlow = this._inCronFlow > 0;
196
+ setImmediate(() => {
197
+ if (!this._ready) {
198
+ process.stderr.write(`[QQ:${this.name}] Cron text chunk dropped (not ready): ${sanitizeLogText(text, 64)} for session ${sanitizeLogText(sessionId, 32)}\n`);
199
+ return;
200
+ }
201
+ if (!wasInCronFlow)
202
+ return;
203
+ if (this.streamState.has(sessionId))
204
+ return;
205
+ let entry = this.cronBuffer.get(sessionId);
206
+ if (!entry) {
207
+ entry = { buffer: '', timer: null };
208
+ this.cronBuffer.set(sessionId, entry);
209
+ }
210
+ if (entry.timer) {
211
+ clearTimeout(entry.timer);
212
+ entry.timer = null;
213
+ if (entry.pendingRetry) {
214
+ entry.buffer = entry.pendingRetry + entry.buffer;
215
+ entry.pendingRetry = '';
216
+ }
217
+ }
218
+ entry.buffer += text;
219
+ const limit = this.qqConfig.bufferFlushLength ?? QQChannel.MAX_BUFFER_LENGTH;
220
+ const delay = entry.buffer.length >= limit ? 0 : 2000;
221
+ entry.timer = setTimeout(() => {
222
+ const toFlush = entry.buffer;
223
+ entry.buffer = '';
224
+ entry.timer = null;
225
+ if (toFlush) {
226
+ const target = this.router.getTarget(sessionId);
227
+ if (target) {
228
+ this.sendMessage(target.chatId, toFlush)
229
+ .then(() => {
230
+ if (!entry.buffer && this.cronBuffer.get(sessionId) === entry)
231
+ this.cronBuffer.delete(sessionId);
232
+ })
233
+ .catch((err) => {
234
+ const code = err instanceof DeliveryError ? err.code : null;
235
+ const codeStr = code ? ` (${code})` : '';
236
+ process.stderr.write(`[QQ:${this.name}] Cron flush send error${codeStr}: ${sanitizeLogText(err instanceof Error ? err.message : String(err), 200)}\n`);
237
+ if (code === 'RETRY_EXHAUSTED' ||
238
+ code === 'ACTIVE_MSG_DISABLED' ||
239
+ code === 'FALLBACK_FAILED') {
240
+ this.cronBuffer.delete(sessionId);
241
+ return;
242
+ }
243
+ entry.pendingRetry = toFlush;
244
+ entry.retryCount = 1;
245
+ if (entry.timer) {
246
+ clearTimeout(entry.timer);
247
+ }
248
+ entry.timer = setTimeout(() => {
249
+ entry.timer = null;
250
+ entry.pendingRetry = '';
251
+ // Guard: if cronBuffer entry was removed (e.g., group deleted),
252
+ // bail out instead of sending to a deleted group.
253
+ if (this.cronBuffer.get(sessionId) !== entry) {
254
+ return;
255
+ }
256
+ const retryTarget = this.router.getTarget(sessionId);
257
+ if (!retryTarget) {
258
+ process.stderr.write(`[QQ:${this.name}] Cron flush dropped after retry: no target for session ${sanitizeLogText(sessionId, 32)}\n`);
259
+ this.cronBuffer.delete(sessionId);
260
+ return;
261
+ }
262
+ this.sendMessage(retryTarget.chatId, toFlush)
263
+ .then(() => {
264
+ entry.pendingRetry = '';
265
+ if (!entry.buffer &&
266
+ this.cronBuffer.get(sessionId) === entry)
267
+ this.cronBuffer.delete(sessionId);
268
+ })
269
+ .catch((retryErr) => {
270
+ const retryCode = retryErr instanceof DeliveryError
271
+ ? retryErr.code
272
+ : null;
273
+ const retryCodeStr = retryCode ? ` (${retryCode})` : '';
274
+ process.stderr.write(`[QQ:${this.name}] Cron flush retry failed${retryCodeStr}: ${sanitizeLogText(retryErr instanceof Error ? retryErr.message : String(retryErr), 200)}\n`);
275
+ entry.pendingRetry = '';
276
+ if (retryCode === 'RETRY_EXHAUSTED' ||
277
+ retryCode === 'ACTIVE_MSG_DISABLED' ||
278
+ retryCode === 'FALLBACK_FAILED') {
279
+ if (!entry.buffer &&
280
+ this.cronBuffer.get(sessionId) === entry) {
281
+ this.cronBuffer.delete(sessionId);
282
+ }
283
+ return;
284
+ }
285
+ // Transient error on retry (RATE_LIMITED, etc.) — re-schedule with backoff
286
+ entry.retryCount = (entry.retryCount ?? 1) + 1;
287
+ if (entry.timer) {
288
+ clearTimeout(entry.timer);
289
+ }
290
+ entry.pendingRetry = toFlush;
291
+ entry.timer = setTimeout(() => {
292
+ entry.timer = null;
293
+ entry.pendingRetry = '';
294
+ if (this.cronBuffer.get(sessionId) !== entry) {
295
+ return;
296
+ }
297
+ const retryTarget2 = this.router.getTarget(sessionId);
298
+ if (!retryTarget2) {
299
+ process.stderr.write(`[QQ:${this.name}] Cron flush dropped after retry: no target for session ${sanitizeLogText(sessionId, 32)}\n`);
300
+ this.cronBuffer.delete(sessionId);
301
+ return;
302
+ }
303
+ this.sendMessage(retryTarget2.chatId, toFlush)
304
+ .then(() => {
305
+ entry.pendingRetry = '';
306
+ if (!entry.buffer &&
307
+ this.cronBuffer.get(sessionId) === entry)
308
+ this.cronBuffer.delete(sessionId);
309
+ })
310
+ .catch((err2) => {
311
+ const code2 = err2 instanceof DeliveryError
312
+ ? err2.code
313
+ : null;
314
+ const code2Str = code2 ? ` (${code2})` : '';
315
+ process.stderr.write(`[QQ:${this.name}] Cron flush re-retry failed${code2Str}: ${sanitizeLogText(err2 instanceof Error ? err2.message : String(err2), 200)}, toFlush=${toFlush.length}, session=${sanitizeLogText(sessionId, 32)}\n`);
316
+ entry.pendingRetry = '';
317
+ if (code2 === 'RETRY_EXHAUSTED' ||
318
+ code2 === 'ACTIVE_MSG_DISABLED' ||
319
+ code2 === 'FALLBACK_FAILED') {
320
+ this.cronBuffer.delete(sessionId);
321
+ return;
322
+ }
323
+ // Transient error: retries exhausted after 3 total attempts
324
+ process.stderr.write(`[QQ:${this.name}] Cron flush retries exhausted, dropped ${toFlush.length} chars for session ${sanitizeLogText(sessionId, 32)}\n`);
325
+ this.cronBuffer.delete(sessionId);
326
+ });
327
+ }, entry.retryCount === 2 ? 10000 : 5000);
328
+ entry.timer.unref();
329
+ });
330
+ }, 5000);
331
+ entry.timer.unref();
332
+ });
333
+ return;
334
+ }
335
+ }
336
+ process.stderr.write(`[QQ:${this.name}] Cron flush dropped: no target for session ${sanitizeLogText(sessionId, 32)}, lost ${toFlush.length} chars\n`);
337
+ this.cronBuffer.delete(sessionId);
338
+ }, delay).unref();
339
+ });
340
+ }
341
+ /**
342
+ * Public gate for external cron/scheduler integration.
343
+ * Wraps a cron message flow to activate `_inCronFlow` so that
344
+ * `textChunk` events are captured into the cron accumulation buffer.
345
+ * Uses a depth counter (not boolean) so concurrent cron flows
346
+ * don't stomp each other's `_inCronFlow` state.
347
+ * Always decrements `_inCronFlow` in a `finally` block.
348
+ */
349
+ async runCronFlow(fn) {
350
+ this._inCronFlow++;
351
+ try {
352
+ await fn();
353
+ }
354
+ finally {
355
+ if (this._inCronFlow > 0)
356
+ this._inCronFlow--;
357
+ }
358
+ }
359
+ /**
360
+ * Override setBridge to re-attach the permanent `_cronTextHandler`
361
+ * after bridge crash-recovery.
362
+ */
363
+ setBridge(bridge) {
364
+ this.detachCronHandler();
365
+ super.setBridge(bridge);
366
+ this.attachCronHandler();
367
+ }
368
+ // ── ChannelBase interface ──────────────────────────────────────
369
+ async connect() {
370
+ // Clear any pending reconnect timer from a previous disconnect/reconnect
371
+ // chain — connect() is an explicit call and should not race with stale
372
+ // reconnectWithRetry timeouts.
373
+ if (this.reconnectTimer) {
374
+ clearTimeout(this.reconnectTimer);
375
+ this.reconnectTimer = null;
376
+ }
377
+ this._reconnectId++;
378
+ this.disposed = false;
379
+ this.reconnectAttempts = 0;
380
+ this.serverRequestedReconnect = false;
381
+ this.tryResume = false;
382
+ if (!this.config.instructions) {
383
+ const parts = [
384
+ '## QQ Bot Channel',
385
+ '',
386
+ '你是通过 QQ Bot 与用户对话的 AI 助手。',
387
+ '支持 Markdown 格式,回复自然流畅即可。',
388
+ '消息前缀 [atMention=true] 表示该消息 @了你,[atMention=false] 表示未 @你。',
389
+ '不想回复时只输出 <noreply> 即可,消息不会发出。',
390
+ '',
391
+ '以下规则仅适用于群聊消息。C2C 私聊中请始终正常回复。',
392
+ '## 群聊唤醒与静默规则',
393
+ '',
394
+ '### 当 [atMention=false] — 未 @你',
395
+ '由你自主判断当前聊天氛围是否适合插嘴:',
396
+ '- 闲聊/调侃/玩梗 → 可以接茬,风趣即可',
397
+ '- 严肃讨论/事务协商 → 保持沉默',
398
+ '- 不确定 → 沉默',
399
+ '',
400
+ '### 当 [atMention=true] — @了你',
401
+ '先去掉 @标签和你的名字,剩下的内容是对你的提问或指令吗?',
402
+ '',
403
+ '以下场景即使 @了你也必须沉默:',
404
+ '1. 纯提及/陈述 — "QwenCode 好像变聪明了"',
405
+ '2. 转述/引用 — "刚才 QwenCode 给的方案可以"',
406
+ '3. 间接呼叫 — "@李四 你让 QwenCode 查下"',
407
+ '4. 调侃/试探 — "这事 QwenCode 肯定不知道"',
408
+ '',
409
+ '### 回复准则',
410
+ '- 被唤醒后直接做事,禁止"我在"等占位回复',
411
+ '- 一条消息 @多人时,只有明确指派给你才接',
412
+ '- 不确认时先沉默',
413
+ '- 完成对话后立刻回归静默',
414
+ ];
415
+ // Only inject @mention format instructions when the operator has
416
+ // opted in (default: enabled). When disabled, the model receives
417
+ // no <@OPENID> tags and has no way to @mention, so the instructions
418
+ // are unnecessary and would confuse the model.
419
+ if (this.qqConfig.allowMention !== false) {
420
+ parts.push('', '## @提及格式', '', '消息内容中的 <@OPENID> 标签代表群成员的 QQ 标识。', '当其他群成员 @你(机器人)时,消息内容中会出现 <@你的BotOPENID> 标签,这代表该消息是 @给你的。机器人自己的 OPENID 将在连接建立后告知。', '你可以在回复中使用 <@OPENID> 格式来 @提及特定的群成员。', '例如:回复 "<@ABC123DEF456> 你好" 会在群里 @该成员。');
421
+ }
422
+ this.config.instructions = parts.join('\n');
423
+ }
424
+ for (let attempt = 0; attempt < 3; attempt++) {
425
+ try {
426
+ await this.fetchToken();
427
+ await this.connectGateway();
428
+ // Register beforeExit hook so the unref'd debounce timer's unflushed
429
+ // state is persisted when the event loop drains naturally. Does NOT
430
+ // fire for SIGKILL, OOM kills, or uncaughtException.
431
+ if (this.beforeExitHook) {
432
+ process.off('beforeExit', this.beforeExitHook);
433
+ }
434
+ this.beforeExitHook = () => this.flushQQState();
435
+ process.on('beforeExit', this.beforeExitHook);
436
+ this.startReplyMsgIdCleanup();
437
+ return;
438
+ }
439
+ catch (e) {
440
+ if (attempt < 2) {
441
+ const msg = e instanceof Error ? e.message : String(e);
442
+ process.stderr.write(`[QQ:${this.name}] Connect attempt ${attempt + 1} failed: ${sanitizeLogText(msg, 200)}, retrying...\n`);
443
+ await this.sleep(2000);
444
+ }
445
+ else {
446
+ // Final attempt: wrap the connection error with sanitized text.
447
+ // The sanitizeLogText path is exercised by the existing connect gateway
448
+ // retry tests in send.test.ts (gateway reconnect timer block).
449
+ throw new Error(sanitizeLogText(e instanceof Error ? e.message : String(e), 200), { cause: e });
450
+ }
451
+ }
452
+ }
453
+ }
454
+ async sendMessage(chatId, text) {
455
+ // <noreply> suppression
456
+ if (text.trim() === '<noreply>') {
457
+ process.stderr.write(`[QQ:${this.name}] <noreply> skipped for ${sanitizeLogText(chatId, 64)}\n`);
458
+ return;
459
+ }
460
+ const route = await this.resolveRoute(chatId);
461
+ if (!route)
462
+ return;
463
+ // Look up reply context with TTL check
464
+ const entry = this.replyMsgId.get(chatId);
465
+ const msgId = entry && Date.now() - entry.timestamp < QQChannel.REPLY_MSG_ID_TTL_MS
466
+ ? entry.msgId
467
+ : undefined;
468
+ if (entry && !msgId) {
469
+ process.stderr.write(`[QQ:${this.name}] replyMsgId entry expired for ${sanitizeLogText(chatId, 64)}, reply context expired, sending without msg_id\n`);
470
+ this.msgSeqMap.delete(entry.msgId);
471
+ this.replyMsgId.delete(chatId);
472
+ this.saveQQState();
473
+ }
474
+ // Respect QQ Bot active-message toggle: when a group admin disables
475
+ // active messages, drop outbound sends silently to avoid platform-policy
476
+ // violations. Only applies to active sends (no msgId — passive replies
477
+ // to @-bot messages must still be delivered).
478
+ if (!msgId && this.groupActiveMsgEnabled.get(chatId) === false) {
479
+ const cronCtx = this._inCronFlow ? ' (cron flow discarded)' : '';
480
+ process.stderr.write(`[QQ:${this.name}] sendMessage blocked: active messages disabled for ${sanitizeLogText(chatId, 64)}${cronCtx}\n`);
481
+ throw new DeliveryError('ACTIVE_MSG_DISABLED', `Active messages disabled for ${sanitizeLogText(chatId, 64)}`);
482
+ }
483
+ let nextSeq = 0;
484
+ let rollbackApplied = false;
485
+ try {
486
+ // ── STEP 1: Passive markdown attempt ──
487
+ const passiveBody = {
488
+ msg_type: 2,
489
+ markdown: { content: text },
490
+ };
491
+ nextSeq = msgId ? (this.msgSeqMap.get(msgId) ?? 0) + 1 : 0;
492
+ if (msgId) {
493
+ this.msgSeqMap.set(msgId, nextSeq);
494
+ passiveBody['msg_id'] = msgId;
495
+ passiveBody['msg_seq'] = nextSeq;
496
+ }
497
+ const resp = await sendQQMessage(route.base, route.path, this.accessToken, passiveBody);
498
+ if (!resp.ok) {
499
+ // Always consume response body to prevent undici resource leak
500
+ const errBody = sanitizeLogText(await resp.text().catch(() => ''), 200);
501
+ // Log diagnostic info for non-429 failures
502
+ if (resp.status !== 429) {
503
+ process.stderr.write(`[QQ:${this.name}] Send failed (HTTP ${resp.status}: ${errBody})\n
504
+ `);
505
+ }
506
+ // 429 = rate-limited — do not retry, bail immediately
507
+ if (resp.status === 429) {
508
+ process.stderr.write(`[QQ:${this.name}] MESSAGE DROPPED: rate-limited (429) on markdown attempt for ${sanitizeLogText(chatId, 64)}\n`);
509
+ if (msgId) {
510
+ this.msgSeqMap.set(msgId, nextSeq - 1);
511
+ this.saveQQState();
512
+ }
513
+ throw new DeliveryError('RATE_LIMITED', `Message blocked by rate limit for ${sanitizeLogText(chatId, 64)}`);
514
+ }
515
+ // Passive markdown failed (non-429). If we have msgId, roll back
516
+ // and try active retries (no msg_id/msg_seq).
517
+ if (msgId) {
518
+ this.msgSeqMap.set(msgId, nextSeq - 1);
519
+ rollbackApplied = true;
520
+ // Check if active messages are allowed for this chat
521
+ if (this.groupActiveMsgEnabled.get(chatId) === false) {
522
+ process.stderr.write(`[QQ:${this.name}] MESSAGE DROPPED: active messages disabled for ${sanitizeLogText(chatId, 64)}, cannot retry\n`);
523
+ this.saveQQState();
524
+ throw new DeliveryError('ACTIVE_MSG_DISABLED', `Active messages disabled for ${sanitizeLogText(chatId, 64)}`);
525
+ }
526
+ // ── STEP 2: Active markdown (msg_type: 2, NO msg_id/msg_seq) ──
527
+ const activeMdBody = {
528
+ msg_type: 2,
529
+ markdown: { content: text },
530
+ };
531
+ const activeMdResp = await sendQQMessage(route.base, route.path, this.accessToken, activeMdBody);
532
+ if (activeMdResp.ok) {
533
+ process.stderr.write(`[QQ:${this.name}] Active markdown retry succeeded for ${sanitizeLogText(chatId, 64)}\n`);
534
+ this.saveQQState();
535
+ await activeMdResp.text().catch(() => '');
536
+ return;
537
+ }
538
+ const mdErrBody = sanitizeLogText(await activeMdResp.text().catch(() => ''), 200);
539
+ if (activeMdResp.status === 429) {
540
+ process.stderr.write(`[QQ:${this.name}] MESSAGE DROPPED: rate-limited (429) on active markdown retry for ${sanitizeLogText(chatId, 64)}\n`);
541
+ this.saveQQState();
542
+ throw new DeliveryError('RATE_LIMITED', `Message blocked by rate limit for ${sanitizeLogText(chatId, 64)}`);
543
+ }
544
+ process.stderr.write(`[QQ:${this.name}] Active markdown retry failed (HTTP ${activeMdResp.status}: ${mdErrBody}) for ${sanitizeLogText(chatId, 64)}\n`);
545
+ // ── STEP 3: Active plain-text (msg_type: 0, NO msg_id/msg_seq) ──
546
+ const activeTextBody = {
547
+ content: text,
548
+ msg_type: 0,
549
+ };
550
+ const activeTextResp = await sendQQMessage(route.base, route.path, this.accessToken, activeTextBody);
551
+ if (activeTextResp.ok) {
552
+ process.stderr.write(`[QQ:${this.name}] Active text fallback succeeded for ${sanitizeLogText(chatId, 64)}\n`);
553
+ this.saveQQState();
554
+ await activeTextResp.text().catch(() => '');
555
+ return;
556
+ }
557
+ const textErrBody = sanitizeLogText(await activeTextResp.text().catch(() => ''), 200);
558
+ if (activeTextResp.status === 429) {
559
+ process.stderr.write(`[QQ:${this.name}] MESSAGE DROPPED: rate-limited (429) on active text fallback for ${sanitizeLogText(chatId, 64)}\n`);
560
+ this.saveQQState();
561
+ throw new DeliveryError('RATE_LIMITED', `Message blocked by rate limit for ${sanitizeLogText(chatId, 64)}`);
562
+ }
563
+ process.stderr.write(`[QQ:${this.name}] MESSAGE DROPPED: active text fallback failed (HTTP ${activeTextResp.status}: ${textErrBody}) for ${sanitizeLogText(chatId, 64)}\n`);
564
+ this.saveQQState();
565
+ throw new DeliveryError('FALLBACK_FAILED', `All delivery attempts exhausted for ${sanitizeLogText(chatId, 64)}`);
566
+ }
567
+ // Plain-text fallback for pure active messages (no reply context)
568
+ const plainBody = {
569
+ content: text,
570
+ msg_type: 0,
571
+ };
572
+ const fallbackRes = await sendQQMessage(route.base, route.path, this.accessToken, plainBody);
573
+ if (!fallbackRes.ok) {
574
+ const fbErrBody = await fallbackRes.text().catch(() => '');
575
+ if (fallbackRes.status === 429) {
576
+ process.stderr.write(`[QQ:${this.name}] MESSAGE DROPPED: rate-limited (429) on plain-text fallback for ${sanitizeLogText(chatId, 64)}\n`);
577
+ throw new DeliveryError('RATE_LIMITED', `Message blocked by rate limit for ${sanitizeLogText(chatId, 64)}`);
578
+ }
579
+ process.stderr.write(`[QQ:${this.name}] MESSAGE DROPPED: plain-text fallback failed (HTTP ${fallbackRes.status}: ${sanitizeLogText(fbErrBody, 200)}) for ${sanitizeLogText(chatId, 64)}\n`);
580
+ throw new DeliveryError('FALLBACK_FAILED', `Plain-text fallback delivery failed for ${sanitizeLogText(chatId, 64)}`);
581
+ }
582
+ process.stderr.write(`[QQ:${this.name}] Plain-text fallback succeeded for ${sanitizeLogText(chatId, 64)}\n`);
583
+ await fallbackRes.text().catch(() => '');
584
+ return;
585
+ }
586
+ await resp.text().catch(() => '');
587
+ if (msgId)
588
+ this.saveQQState();
589
+ }
590
+ catch (e) {
591
+ // Rollback on failure if we haven't already
592
+ if (msgId && !rollbackApplied) {
593
+ this.msgSeqMap.set(msgId, nextSeq - 1);
594
+ }
595
+ if (msgId)
596
+ this.saveQQState();
597
+ // Note: sendQQMessage only throws on network/timeout errors, never HTTP status.
598
+ // Rate-limit (429) handling is in the resp.status checks above.
599
+ if (!(e instanceof DeliveryError)) {
600
+ process.stderr.write(`[QQ:${this.name}] Send error: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`);
601
+ }
602
+ throw e; // Re-throw for .catch() callers
603
+ }
604
+ }
605
+ /**
606
+ * Resolve API routing: handles disposed check, token refresh, chatId validation,
607
+ * sandbox detection, and C2C/group path selection. Returns null if any guard fails.
608
+ */
609
+ async resolveRoute(chatId) {
610
+ if (this.disposed) {
611
+ process.stderr.write(`[QQ:${this.name}] resolveRoute: channel disposed, dropping message to ${sanitizeLogText(chatId, 64)}\n`);
612
+ return null;
613
+ }
614
+ if (Date.now() >= this.tokenExpiresAt) {
615
+ try {
616
+ await this.fetchToken();
617
+ }
618
+ catch (_e) {
619
+ process.stderr.write(`[QQ:${this.name}] resolveRoute: token refresh failed (${sanitizeLogText(_e instanceof Error ? _e.message : String(_e), 120)}), dropping message to ${sanitizeLogText(chatId, 64)}\n`);
620
+ return null;
621
+ }
622
+ }
623
+ if (!this.accessToken) {
624
+ process.stderr.write(`[QQ:${this.name}] resolveRoute: accessToken is empty after fetchToken\n`);
625
+ return null;
626
+ }
627
+ if (!isValidChatId(chatId)) {
628
+ process.stderr.write(`[QQ:${this.name}] resolveRoute: invalid chatId rejected (length=${chatId.length})\n`);
629
+ return null;
630
+ }
631
+ const base = getApiBase(Boolean(this.qqConfig.sandbox));
632
+ const routeType = this.chatTypeMap.get(chatId) || this.qqConfig.chatTypes?.[chatId];
633
+ if (routeType !== 'group' && routeType !== 'c2c') {
634
+ process.stderr.write(`[QQ:${this.name}] resolveRoute: no chat type for ${sanitizeLogText(chatId, 64)}, dropping message\n`);
635
+ return null;
636
+ }
637
+ const path = routeType === 'group'
638
+ ? `/v2/groups/${chatId}/messages`
639
+ : `/v2/users/${chatId}/messages`;
640
+ return { base, path };
641
+ }
642
+ disconnect() {
643
+ this._reconnectId++;
644
+ this.disposed = true;
645
+ this._ready = false;
646
+ this.stopHeartbeat();
647
+ this.stopTokenRefresh();
648
+ this.stopReplyMsgIdCleanup();
649
+ if (this.seenCleanupTimer) {
650
+ clearInterval(this.seenCleanupTimer);
651
+ this.seenCleanupTimer = null;
652
+ }
653
+ if (this.reconnectTimer) {
654
+ clearTimeout(this.reconnectTimer);
655
+ this.reconnectTimer = null;
656
+ }
657
+ if (this.beforeExitHook) {
658
+ process.off('beforeExit', this.beforeExitHook);
659
+ this.beforeExitHook = null;
660
+ }
661
+ // Clean up cron buffers (always, regardless of config flag)
662
+ let droppedCount = 0;
663
+ for (const [, entry] of this.cronBuffer) {
664
+ if (entry.timer)
665
+ clearTimeout(entry.timer);
666
+ if (entry.buffer)
667
+ droppedCount++;
668
+ }
669
+ if (droppedCount > 0) {
670
+ process.stderr.write(`[QQ:${this.name}] Disconnect: discarding ${droppedCount} buffered cron message(s)\n`);
671
+ }
672
+ this.cronBuffer.clear();
673
+ this._lastKeywordNoMatchLog.clear();
674
+ this.flushQQState();
675
+ this.backupGlobalSessions();
676
+ if (this.readyTimeout) {
677
+ clearTimeout(this.readyTimeout);
678
+ this.readyTimeout = null;
679
+ }
680
+ if (this.ws) {
681
+ this.ws.close(1000);
682
+ this.ws = null;
683
+ }
684
+ if (this.connectReject) {
685
+ this.connectReject(new Error('Channel disconnected'));
686
+ this.connectReject = null;
687
+ }
688
+ this.detachCronHandler();
689
+ this.chatTypeMap.clear();
690
+ this.replyMsgId.clear();
691
+ this.msgSeqMap.clear();
692
+ this.botOpenIdByGroup.clear();
693
+ this.groupActiveMsgEnabled.clear();
694
+ this.seenMessages.clear();
695
+ this.coldStart = true;
696
+ if (this._inCronFlow > 0) {
697
+ process.stderr.write(`[QQ:${this.name}] resetRoutingState: orphaned cron flow (depth=${this._inCronFlow}) during disconnect\n`);
698
+ }
699
+ this._inCronFlow = 0;
700
+ for (const [, state] of this.streamState) {
701
+ if (state.timer)
702
+ clearTimeout(state.timer);
703
+ }
704
+ this.streamState.clear();
705
+ this.flushingSessions.clear();
706
+ this.pendingStreamDelete.clear();
707
+ this.flushedSessions.clear();
708
+ }
709
+ /**
710
+ * QQ Bot API V2 does not provide a typing indicator endpoint.
711
+ * ChannelBase calls these hooks to signal prompt start/end;
712
+ * they are intentionally no-ops for this channel.
713
+ */
714
+ onPromptStart(_chatId, _sessionId, _messageId) { }
715
+ onPromptEnd(_chatId, _sessionId, _messageId) { }
716
+ // ── Streaming (idle-flush with per-session buffers) ────────────
717
+ onResponseChunk(chatId, chunk, sessionId) {
718
+ if (this.blockStreaming)
719
+ return;
720
+ let state = this.streamState.get(sessionId);
721
+ if (!state) {
722
+ state = { chatId, buffer: chunk, timer: null, retryCount: 0 };
723
+ this.streamState.set(sessionId, state);
724
+ }
725
+ else {
726
+ state.buffer += chunk;
727
+ if (state.timer) {
728
+ clearTimeout(state.timer);
729
+ state.timer = null;
730
+ }
731
+ // Size-cap flush: check flushingSessions to prevent concurrent sends.
732
+ if (state.buffer.length >=
733
+ (this.qqConfig.bufferFlushLength ?? QQChannel.MAX_BUFFER_LENGTH)) {
734
+ const buf = state.buffer;
735
+ state.buffer = '';
736
+ if (this.flushingSessions.has(sessionId)) {
737
+ // Send in-flight — re-buffer and let the in-flight send's .then() pick it up
738
+ state.buffer = buf + (state.buffer || '');
739
+ state.timer = setTimeout(() => {
740
+ this.idleFlush(sessionId, this._reconnectId);
741
+ }, QQChannel.IDLE_FLUSH_MS);
742
+ state.timer.unref?.();
743
+ return;
744
+ }
745
+ this.flushAndTrack(sessionId, buf, state, 'idleFlush');
746
+ return;
747
+ }
748
+ }
749
+ const reconnectId = this._reconnectId;
750
+ state.timer = setTimeout(() => {
751
+ this.idleFlush(sessionId, reconnectId);
752
+ }, QQChannel.IDLE_FLUSH_MS);
753
+ state.timer.unref?.();
754
+ }
755
+ idleFlush(sessionId, reconnectId) {
756
+ if (this._reconnectId !== reconnectId) {
757
+ process.stderr.write(`[QQ:${this.name}] idleFlush discarded (reconnect) session=${sanitizeLogText(sessionId, 32)}\n`);
758
+ return;
759
+ }
760
+ const state = this.streamState.get(sessionId);
761
+ if (!state || !state.buffer)
762
+ return;
763
+ if (this.flushingSessions.has(sessionId)) {
764
+ // Another send is in-flight — re-schedule idle timer so we retry later
765
+ if (!state.timer) {
766
+ const retryReconnectId = this._reconnectId;
767
+ state.timer = setTimeout(() => {
768
+ this.idleFlush(sessionId, retryReconnectId);
769
+ }, QQChannel.IDLE_FLUSH_MS);
770
+ state.timer.unref?.();
771
+ }
772
+ return;
773
+ }
774
+ const buffer = state.buffer;
775
+ state.buffer = '';
776
+ state.timer = null; // Clear expired one-shot timer reference
777
+ this.flushAndTrack(sessionId, buffer, state, 'idleFlush');
778
+ }
779
+ /**
780
+ * Shared send-and-track helper used by idleFlush and onToolCall.
781
+ * Encapsulates .then() (cleanup on success) and .catch() (retry/re-buffer
782
+ * on failure) logic to eliminate duplication.
783
+ */
784
+ flushAndTrack(sessionId, buffer, state, logLabel) {
785
+ this.flushingSessions.add(sessionId);
786
+ // sendMessage throws DeliveryError for delivery failures.
787
+ // RETRY_EXHAUSTED, ACTIVE_MSG_DISABLED, and FALLBACK_FAILED are
788
+ // permanent. RATE_LIMITED is transient and falls through to re-buffer/retry.
789
+ this.sendMessage(state.chatId, buffer)
790
+ .then(() => {
791
+ // #3: Guard — if session died during in-flight send, touch nothing
792
+ const current = this.streamState.get(sessionId);
793
+ if (current !== state)
794
+ return;
795
+ current.retryCount = 0;
796
+ this.flushedSessions.add(sessionId);
797
+ if (this.pendingStreamDelete.has(sessionId)) {
798
+ this.pendingStreamDelete.delete(sessionId);
799
+ // #2: Flush immediately — idle timer would add unnecessary delay
800
+ const s = this.streamState.get(sessionId);
801
+ if (s === state && s.buffer) {
802
+ // Don't clear buffer or retryCount — idleFlush will pick them up.
803
+ this.idleFlush(sessionId, this._reconnectId);
804
+ // Don't return — let .finally() clear flushingSessions
805
+ // so deferred idleFlush can proceed.
806
+ }
807
+ }
808
+ // #8: Clean up streamState only if no content arrived during send
809
+ const s = this.streamState.get(sessionId);
810
+ if (s === state && !s.buffer) {
811
+ this.streamState.delete(sessionId);
812
+ }
813
+ })
814
+ .catch((e) => {
815
+ if (e instanceof DeliveryError &&
816
+ (e.code === 'RETRY_EXHAUSTED' ||
817
+ e.code === 'ACTIVE_MSG_DISABLED' ||
818
+ e.code === 'FALLBACK_FAILED')) {
819
+ process.stderr.write(`[QQ:${this.name}] ${logLabel} delivery failed (${e.code}): ${sanitizeLogText(e.message, 200)}, dropping ${buffer.length} chars\n`);
820
+ // RETRY_EXHAUSTED / ACTIVE_MSG_DISABLED / FALLBACK_FAILED = permanent failure.
821
+ // Drop everything — including any residual buffer that arrived concurrently.
822
+ const current = this.streamState.get(sessionId);
823
+ if (current === state) {
824
+ this.streamState.delete(sessionId);
825
+ }
826
+ if (this.pendingStreamDelete.has(sessionId)) {
827
+ this.pendingStreamDelete.delete(sessionId);
828
+ this.flushedSessions.delete(sessionId);
829
+ }
830
+ return;
831
+ }
832
+ process.stderr.write(`[QQ:${this.name}] ${logLabel} send failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`);
833
+ // #1: Never undo previously-succeeded flush records on failure
834
+ if (this.pendingStreamDelete.has(sessionId)) {
835
+ // Session is ending - retry up to MAX_FLUSH_RETRIES
836
+ this.pendingStreamDelete.delete(sessionId);
837
+ const current = this.streamState.get(sessionId);
838
+ if (current === state) {
839
+ current.buffer = buffer;
840
+ current.retryCount++;
841
+ if (this.maxFlushRetries <= 0 ||
842
+ current.retryCount < this.maxFlushRetries) {
843
+ const reconnectId = this._reconnectId;
844
+ const delay = current.retryCount > 1
845
+ ? QQChannel.IDLE_FLUSH_BACKOFF_MS
846
+ : QQChannel.IDLE_FLUSH_MS;
847
+ current.timer = setTimeout(() => {
848
+ this.idleFlush(sessionId, reconnectId);
849
+ }, delay);
850
+ current.timer.unref?.();
851
+ }
852
+ else {
853
+ this.streamState.delete(sessionId);
854
+ // #2: Clean up flushedSessions on retry exhaustion
855
+ this.flushedSessions.delete(sessionId);
856
+ process.stderr.write(`[QQ:${this.name}] ${logLabel} retries exhausted for ${sanitizeLogText(sessionId, 64)}\n`);
857
+ }
858
+ }
859
+ }
860
+ else {
861
+ // Not ending - re-buffer and retry
862
+ const current = this.streamState.get(sessionId);
863
+ // #6: Identity guard — only operate on the same state reference
864
+ if (current === state) {
865
+ current.buffer = buffer + (current.buffer || '');
866
+ // #3: If re-buffer exceeds max length, flush immediately
867
+ if (current.buffer.length >=
868
+ (this.qqConfig.bufferFlushLength ?? QQChannel.MAX_BUFFER_LENGTH)) {
869
+ current.retryCount++;
870
+ if (this.maxFlushRetries > 0 &&
871
+ current.retryCount >= this.maxFlushRetries) {
872
+ this.streamState.delete(sessionId);
873
+ this.flushedSessions.delete(sessionId);
874
+ process.stderr.write(`[QQ:${this.name}] ${logLabel} retries exhausted (buffer exceeds limit) for ${sanitizeLogText(sessionId, 64)}\n`);
875
+ }
876
+ else {
877
+ this.idleFlush(sessionId, this._reconnectId);
878
+ }
879
+ }
880
+ else {
881
+ current.retryCount++;
882
+ if (this.maxFlushRetries <= 0 ||
883
+ current.retryCount < this.maxFlushRetries) {
884
+ if (!current.timer) {
885
+ const reconnectId = this._reconnectId;
886
+ const delay = current.retryCount > 1
887
+ ? QQChannel.IDLE_FLUSH_BACKOFF_MS
888
+ : QQChannel.IDLE_FLUSH_MS;
889
+ current.timer = setTimeout(() => {
890
+ this.idleFlush(sessionId, reconnectId);
891
+ }, delay);
892
+ current.timer.unref?.();
893
+ }
894
+ }
895
+ else {
896
+ this.streamState.delete(sessionId);
897
+ // #2: Clean up flushedSessions on retry exhaustion
898
+ this.flushedSessions.delete(sessionId);
899
+ process.stderr.write(`[QQ:${this.name}] ${logLabel} retries exhausted for ${sanitizeLogText(sessionId, 64)}\n`);
900
+ }
901
+ }
902
+ }
903
+ }
904
+ })
905
+ .finally(() => {
906
+ // #1: Identity guard — only delete if no new state replaced us
907
+ const current = this.streamState.get(sessionId);
908
+ if (!current || current === state) {
909
+ this.flushingSessions.delete(sessionId);
910
+ }
911
+ });
912
+ }
913
+ onToolCall(_chatId, event) {
914
+ const state = this.streamState.get(event.sessionId);
915
+ if (!state || !state.buffer)
916
+ return;
917
+ if (this.flushingSessions.has(event.sessionId))
918
+ return;
919
+ if (state.timer) {
920
+ clearTimeout(state.timer);
921
+ state.timer = null;
922
+ }
923
+ const buffer = state.buffer;
924
+ state.buffer = '';
925
+ this.flushAndTrack(event.sessionId, buffer, state, 'toolCallFlush');
926
+ }
927
+ onResponseBoundary(_chatId, sessionId) {
928
+ const state = this.streamState.get(sessionId);
929
+ if (state?.timer) {
930
+ clearTimeout(state.timer);
931
+ }
932
+ this.streamState.delete(sessionId);
933
+ this.flushingSessions.delete(sessionId);
934
+ this.pendingStreamDelete.delete(sessionId);
935
+ this.flushedSessions.delete(sessionId);
936
+ }
937
+ async onResponseComplete(chatId, fullText, sessionId) {
938
+ const state = this.streamState.get(sessionId);
939
+ if (state?.timer) {
940
+ clearTimeout(state.timer);
941
+ state.timer = null;
942
+ }
943
+ if (state && this.flushingSessions.has(sessionId)) {
944
+ this.pendingStreamDelete.add(sessionId);
945
+ process.stderr.write(`[QQ:${this.name}] onResponseComplete deferred (flush in-flight) session=${sanitizeLogText(sessionId, 32)}\n`);
946
+ return;
947
+ }
948
+ const wasFlushed = this.flushedSessions.has(sessionId);
949
+ const remaining = state?.buffer ?? (wasFlushed ? '' : fullText);
950
+ this.streamState.delete(sessionId);
951
+ this.flushedSessions.delete(sessionId);
952
+ if (remaining) {
953
+ await super.onResponseComplete(chatId, remaining, sessionId);
954
+ }
955
+ }
956
+ onSessionDied(sessionId) {
957
+ const state = this.streamState.get(sessionId);
958
+ if (state?.timer) {
959
+ clearTimeout(state.timer);
960
+ }
961
+ this.streamState.delete(sessionId);
962
+ this.flushingSessions.delete(sessionId);
963
+ this.pendingStreamDelete.delete(sessionId);
964
+ this.flushedSessions.delete(sessionId);
965
+ super.onSessionDied(sessionId);
966
+ }
967
+ // ── State Persistence (cross-server context continuation) ──────
968
+ serializeQQState() {
969
+ return JSON.stringify({
970
+ chatTypeMap: Array.from(this.chatTypeMap.entries()),
971
+ replyMsgId: Array.from(this.replyMsgId.entries()),
972
+ msgSeqMap: Array.from(this.msgSeqMap.entries()),
973
+ groupActiveMsgEnabled: Array.from(this.groupActiveMsgEnabled.entries()),
974
+ botOpenIdByGroup: Array.from(this.botOpenIdByGroup.entries()),
975
+ });
976
+ }
977
+ /** Debounced state persistence with atomic write. */
978
+ saveQQState() {
979
+ // NOTE: guarded here; flushQQState() is intentionally NOT — disconnect()
980
+ // sets disposed=true *before* calling it, so it must still write final state.
981
+ if (this.disposed)
982
+ return;
983
+ if (this.saveTimer)
984
+ clearTimeout(this.saveTimer);
985
+ this.saveTimer = setTimeout(() => {
986
+ if (this.disposed)
987
+ return;
988
+ const tmpPath = this.qqStatePath + '.tmp';
989
+ try {
990
+ writeFileSync(tmpPath, this.serializeQQState(), { mode: 0o600 });
991
+ renameSync(tmpPath, this.qqStatePath);
992
+ }
993
+ catch (e) {
994
+ try {
995
+ unlinkSync(tmpPath);
996
+ }
997
+ catch {
998
+ /* best-effort cleanup */
999
+ }
1000
+ process.stderr.write(`[QQ:${this.name}] saveQQState write failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n
1001
+ `);
1002
+ }
1003
+ }, 500);
1004
+ this.saveTimer.unref();
1005
+ }
1006
+ /**
1007
+ * Attach the permanent textChunk handler for cron/non-prompt messages
1008
+ * to the current bridge. No-op if already attached or if cron is disabled.
1009
+ */
1010
+ attachCronHandler() {
1011
+ if (this.qqConfig['cron-msg-experimental'] &&
1012
+ this._cronTextHandler &&
1013
+ !this.cronTextHandlerAttached) {
1014
+ this.bridge.on?.('textChunk', this._cronTextHandler);
1015
+ this.cronTextHandlerAttached = true;
1016
+ }
1017
+ }
1018
+ _checkGroupAllPolicyRequireMention() {
1019
+ const policy = this.qqConfig.groupAllPolicy;
1020
+ if (policy !== 'keyword' && policy !== 'all')
1021
+ return;
1022
+ const groups = this.config.groups;
1023
+ const anyFalse = groups && Object.values(groups).some((g) => g.requireMention === false);
1024
+ if (!anyFalse) {
1025
+ process.stderr.write(`[QQ:${this.name}] WARNING: groupAllPolicy is '${policy}' but requireMention is true (default). Non-@-bot messages passing keyword/all policy will be silently dropped by GroupGate. Set 'groups': { '*': { 'requireMention': false } } in channel config.\n`);
1026
+ }
1027
+ }
1028
+ /**
1029
+ * Detach the permanent textChunk handler from the current bridge.
1030
+ * No-op if not attached or if cron is disabled.
1031
+ */
1032
+ detachCronHandler() {
1033
+ if (this.qqConfig['cron-msg-experimental'] &&
1034
+ this._cronTextHandler &&
1035
+ this.cronTextHandlerAttached) {
1036
+ this.bridge.off?.('textChunk', this._cronTextHandler);
1037
+ this.cronTextHandlerAttached = false;
1038
+ }
1039
+ }
1040
+ /** Flush pending state writes immediately (called on disconnect). */
1041
+ flushQQState() {
1042
+ if (this.saveTimer) {
1043
+ clearTimeout(this.saveTimer);
1044
+ this.saveTimer = null;
1045
+ }
1046
+ const tmpPath = this.qqStatePath + '.tmp';
1047
+ try {
1048
+ writeFileSync(tmpPath, this.serializeQQState(), { mode: 0o600 });
1049
+ renameSync(tmpPath, this.qqStatePath);
1050
+ }
1051
+ catch (e) {
1052
+ try {
1053
+ unlinkSync(tmpPath);
1054
+ }
1055
+ catch {
1056
+ /* best-effort cleanup */
1057
+ }
1058
+ process.stderr.write(`[QQ:${this.name}] flushQQState write failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n
1059
+ `);
1060
+ }
1061
+ }
1062
+ /**
1063
+ * Restore QQ routing state from disk.
1064
+ *
1065
+ * Validates all restored state extensively — type checks, length bounds,
1066
+ * and sanity filters — so a corrupted file produces clean empty maps
1067
+ * rather than propagating invalid data.
1068
+ */
1069
+ restoreQQState() {
1070
+ try {
1071
+ if (!existsSync(this.qqStatePath))
1072
+ return false;
1073
+ const raw = JSON.parse(readFileSync(this.qqStatePath, 'utf-8'));
1074
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1075
+ process.stderr.write(`[QQ:${this.name}] Invalid QQ state file (not an object), ignoring\n`);
1076
+ return false;
1077
+ }
1078
+ if (raw.chatTypeMap) {
1079
+ const arr = raw.chatTypeMap;
1080
+ const totalRaw = Array.isArray(arr) ? arr.length : 0;
1081
+ this.chatTypeMap = new Map(Array.isArray(arr)
1082
+ ? arr.filter(([k, v]) => typeof k === 'string' &&
1083
+ k.length <= 256 &&
1084
+ (v === 'c2c' || v === 'group'))
1085
+ : []);
1086
+ if (this.chatTypeMap.size < totalRaw) {
1087
+ process.stderr.write(`[QQ:${this.name}] restoreQQState: accepted ${this.chatTypeMap.size} chatTypeMap entries (rejected ${totalRaw - this.chatTypeMap.size})\n`);
1088
+ }
1089
+ }
1090
+ if (raw.replyMsgId) {
1091
+ const arr = raw.replyMsgId;
1092
+ const totalRaw = Array.isArray(arr) ? arr.length : 0;
1093
+ this.replyMsgId = new Map(Array.isArray(arr)
1094
+ ? arr
1095
+ .filter(([k]) => typeof k === 'string' && k.length <= 256)
1096
+ .filter(([, v]) => {
1097
+ if (typeof v === 'string' && v.length <= 128)
1098
+ return true;
1099
+ if (v === null || typeof v !== 'object')
1100
+ return false;
1101
+ const o = v;
1102
+ return (typeof o['msgId'] === 'string' &&
1103
+ o['msgId'].length <= 128 &&
1104
+ typeof o['timestamp'] === 'number' &&
1105
+ Number.isFinite(o['timestamp']) &&
1106
+ o['timestamp'] <= Date.now() + QQChannel.REPLY_MSG_ID_TTL_MS);
1107
+ })
1108
+ .map(([k, v]) => [
1109
+ k,
1110
+ typeof v === 'string'
1111
+ ? { msgId: v, timestamp: Date.now() }
1112
+ : v,
1113
+ ])
1114
+ : []);
1115
+ if (this.replyMsgId.size < totalRaw) {
1116
+ process.stderr.write(`[QQ:${this.name}] restoreQQState: accepted ${this.replyMsgId.size} replyMsgId entries (rejected ${totalRaw - this.replyMsgId.size})\n`);
1117
+ }
1118
+ }
1119
+ if (raw.msgSeqMap) {
1120
+ const arr = raw.msgSeqMap;
1121
+ const totalRaw = Array.isArray(arr) ? arr.length : 0;
1122
+ this.msgSeqMap = new Map(Array.isArray(arr)
1123
+ ? arr.filter(([k, v]) => typeof k === 'string' &&
1124
+ k.length <= 256 &&
1125
+ typeof v === 'number' &&
1126
+ Number.isSafeInteger(v) &&
1127
+ v >= 0)
1128
+ : []);
1129
+ if (this.msgSeqMap.size < totalRaw) {
1130
+ process.stderr.write(`[QQ:${this.name}] restoreQQState: accepted ${this.msgSeqMap.size} msgSeqMap entries (rejected ${totalRaw - this.msgSeqMap.size})\n`);
1131
+ }
1132
+ }
1133
+ if (raw.groupActiveMsgEnabled) {
1134
+ const arr = raw.groupActiveMsgEnabled;
1135
+ const totalRaw = Array.isArray(arr) ? arr.length : 0;
1136
+ this.groupActiveMsgEnabled = new Map(Array.isArray(arr)
1137
+ ? arr.filter(([k, v]) => typeof k === 'string' &&
1138
+ k.length <= 256 &&
1139
+ typeof v === 'boolean')
1140
+ : []);
1141
+ if (this.groupActiveMsgEnabled.size < totalRaw) {
1142
+ process.stderr.write(`[QQ:${this.name}] restoreQQState: accepted ${this.groupActiveMsgEnabled.size} groupActiveMsgEnabled entries (rejected ${totalRaw - this.groupActiveMsgEnabled.size})\n`);
1143
+ }
1144
+ }
1145
+ if (raw.botOpenIdByGroup) {
1146
+ const arr = raw.botOpenIdByGroup;
1147
+ const totalRaw = Array.isArray(arr) ? arr.length : 0;
1148
+ this.botOpenIdByGroup = new Map(Array.isArray(arr)
1149
+ ? arr.filter(([k, v]) => typeof k === 'string' &&
1150
+ k.length <= 256 &&
1151
+ typeof v === 'string' &&
1152
+ /^[A-F0-9]{32}$/i.test(v))
1153
+ : []);
1154
+ if (this.botOpenIdByGroup.size < totalRaw) {
1155
+ process.stderr.write(`[QQ:${this.name}] restoreQQState: accepted ${this.botOpenIdByGroup.size} botOpenIdByGroup entries (rejected ${totalRaw - this.botOpenIdByGroup.size})\n`);
1156
+ }
1157
+ }
1158
+ return true;
1159
+ }
1160
+ catch (e) {
1161
+ process.stderr.write(`[QQ:${this.name}] Failed to restore QQ state: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`);
1162
+ return false;
1163
+ }
1164
+ }
1165
+ /**
1166
+ * Backup the global sessions.json before start.ts deletes it on shutdown.
1167
+ * Restored on next connect so conversations survive daemon restarts.
1168
+ */
1169
+ backupGlobalSessions() {
1170
+ try {
1171
+ if (existsSync(this.globalSessionsPath)) {
1172
+ const data = readFileSync(this.globalSessionsPath, 'utf-8');
1173
+ if (data.trim())
1174
+ writeFileSync(this.sessionsBackupPath, data, { mode: 0o600 });
1175
+ }
1176
+ }
1177
+ catch (e) {
1178
+ process.stderr.write(`[QQ:${this.name}] backupGlobalSessions failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n
1179
+ `);
1180
+ }
1181
+ }
1182
+ restoreGlobalSessions() {
1183
+ try {
1184
+ if (!existsSync(this.globalSessionsPath) &&
1185
+ existsSync(this.sessionsBackupPath)) {
1186
+ writeFileSync(this.globalSessionsPath, readFileSync(this.sessionsBackupPath, 'utf-8'), { mode: 0o600 });
1187
+ }
1188
+ }
1189
+ catch (e) {
1190
+ process.stderr.write(`[QQ:${this.name}] restoreGlobalSessions failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n
1191
+ `);
1192
+ }
1193
+ }
1194
+ /**
1195
+ * Compatibility repair for legacy restored session state where older router
1196
+ * code could keep an empty session id after bridge.loadSession() failed to
1197
+ * return a session_id.
1198
+ *
1199
+ * **Fragile**: accesses SessionRouter's private `toSession`/`toTarget`/`toCwd`
1200
+ * maps via type coercion. If SessionRouter internals change, this breaks
1201
+ * silently. The only signal will be cross-server conversations failing to
1202
+ * restore after daemon restart — no crash, no log.
1203
+ *
1204
+ * Keep this while old persisted files may still exist.
1205
+ */
1206
+ fixRestoredSessions() {
1207
+ try {
1208
+ if (!existsSync(this.globalSessionsPath))
1209
+ return;
1210
+ const raw = JSON.parse(readFileSync(this.globalSessionsPath, 'utf-8'));
1211
+ const r = this.router;
1212
+ const tm = r['toSession'];
1213
+ const tt = r['toTarget'];
1214
+ const tc = r['toCwd'];
1215
+ if (!tm || !tt)
1216
+ return;
1217
+ for (const [key, sid] of tm) {
1218
+ if (sid)
1219
+ continue;
1220
+ const entry = raw[key];
1221
+ if (!entry?.sessionId)
1222
+ continue;
1223
+ const correctId = entry.sessionId;
1224
+ const target = entry.target;
1225
+ tm.set(key, correctId);
1226
+ tt.delete(undefined);
1227
+ tt.set(correctId, target);
1228
+ if (tc) {
1229
+ tc.delete(undefined);
1230
+ tc.set(correctId, entry.cwd || '');
1231
+ }
1232
+ }
1233
+ }
1234
+ catch (e) {
1235
+ process.stderr.write(`[QQ:${this.name}] fixRestoredSessions failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n
1236
+ `);
1237
+ }
1238
+ }
1239
+ // ── ReplyMsgId helpers ────────────────────────────────────────
1240
+ /**
1241
+ * Set replyMsgId for a chat, cleaning up the previous entry's msgSeqMap
1242
+ * to prevent orphaned entries accumulating over time.
1243
+ */
1244
+ setReplyMsgId(chatId, msgId) {
1245
+ const oldEntry = this.replyMsgId.get(chatId);
1246
+ if (oldEntry && oldEntry.msgId !== msgId) {
1247
+ this.msgSeqMap.delete(oldEntry.msgId);
1248
+ }
1249
+ this.replyMsgId.set(chatId, { msgId, timestamp: Date.now() });
1250
+ this.saveQQState();
1251
+ }
1252
+ /**
1253
+ * Start periodic cleanup of expired replyMsgId entries.
1254
+ * Evicts entries older than 5 minutes every 60 seconds, and cascades
1255
+ * to msgSeqMap.
1256
+ */
1257
+ startReplyMsgIdCleanup() {
1258
+ this.stopReplyMsgIdCleanup();
1259
+ this.replyMsgIdCleanupTimer = setInterval(() => {
1260
+ const cutoff = Date.now() - QQChannel.REPLY_MSG_ID_TTL_MS;
1261
+ let dirty = false;
1262
+ for (const [chatId, entry] of this.replyMsgId) {
1263
+ if (entry.timestamp < cutoff) {
1264
+ this.msgSeqMap.delete(entry.msgId);
1265
+ this.replyMsgId.delete(chatId);
1266
+ dirty = true;
1267
+ }
1268
+ }
1269
+ if (dirty)
1270
+ this.saveQQState();
1271
+ }, 60_000);
1272
+ this.replyMsgIdCleanupTimer.unref();
1273
+ }
1274
+ stopReplyMsgIdCleanup() {
1275
+ if (this.replyMsgIdCleanupTimer) {
1276
+ clearInterval(this.replyMsgIdCleanupTimer);
1277
+ this.replyMsgIdCleanupTimer = null;
1278
+ }
1279
+ }
1280
+ // ── Token ──────────────────────────────────────────────────────
1281
+ async fetchToken() {
1282
+ const safeName = this.name.replace(/[^A-Za-z0-9_-]/g, '_');
1283
+ const credsFile = getCredsFilePath(safeName);
1284
+ let appID = this.qqConfig.appID;
1285
+ let appSecret = this.qqConfig.appSecret;
1286
+ if (!appID || !appSecret) {
1287
+ const saved = loadCredentials(credsFile);
1288
+ if (saved) {
1289
+ appID = saved.appId;
1290
+ appSecret = saved.appSecret;
1291
+ this.qqConfig.appID = appID;
1292
+ this.qqConfig.appSecret = appSecret;
1293
+ }
1294
+ }
1295
+ if (!appID || !appSecret) {
1296
+ process.stderr.write(`[QQ:${this.name}] No credentials, scan QR code with QQ...\n`);
1297
+ const creds = await qrCodeLogin();
1298
+ appID = creds.appId;
1299
+ appSecret = creds.appSecret;
1300
+ this.qqConfig.appID = appID;
1301
+ this.qqConfig.appSecret = appSecret;
1302
+ saveCredentials(credsFile, appID, appSecret);
1303
+ }
1304
+ const token = await fetchAccessToken(appID, appSecret);
1305
+ this.accessToken = token.accessToken;
1306
+ this.tokenExpiresAt = Date.now() + token.expiresIn * 1000;
1307
+ this.scheduleTokenRefresh();
1308
+ }
1309
+ scheduleTokenRefresh() {
1310
+ if (this.disposed)
1311
+ return;
1312
+ this.stopTokenRefresh();
1313
+ const ttl = Math.max(0, this.tokenExpiresAt - Date.now());
1314
+ // Refresh at 80% of TTL, at least 10s before expiry, at most ttl-30s
1315
+ const delay = Math.min(ttl * 0.8, Math.max(ttl - 30_000, 10_000));
1316
+ if (delay > 0) {
1317
+ const tokenReconnectId = this._reconnectId;
1318
+ this.tokenRefreshTimer = setTimeout(() => {
1319
+ this.fetchToken().catch((e) => {
1320
+ if (this.disposed || this._reconnectId !== tokenReconnectId)
1321
+ return;
1322
+ process.stderr.write(`[QQ:${this.name}] Token refresh failed: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}, will retry\n
1323
+ `);
1324
+ // Retry up to 10 times at 60s intervals, then give up.
1325
+ // Token refresh failure after 10 attempts (10 min) indicates
1326
+ // a persistent issue (revoked credentials, DNS, firewall) that
1327
+ // won't resolve by retrying — disconnect and reconnect so the
1328
+ // fresh connection re-fetches the token, preventing zombie-state
1329
+ // where the WS stays connected but outbound messages are dropped.
1330
+ let retryCount = 0;
1331
+ const retry = () => {
1332
+ if (this.disposed || this._reconnectId !== tokenReconnectId)
1333
+ return;
1334
+ if (++retryCount > 10) {
1335
+ process.stderr.write(`[QQ:${this.name}] FATAL: token refresh exhausted, reconnecting\n
1336
+ `);
1337
+ this.isReconnecting = true;
1338
+ this.disconnect();
1339
+ const postDisconnectReconnectId = this._reconnectId;
1340
+ this.reconnectTimer = setTimeout(() => {
1341
+ if (this._reconnectId !== postDisconnectReconnectId)
1342
+ return;
1343
+ this.isReconnecting = false;
1344
+ this.disposed = false;
1345
+ // Use reconnectWithRetry instead of bare connect() —
1346
+ // gives exponential backoff + maxReconnectAttempts guard,
1347
+ // preventing zombie state where the channel is permanently
1348
+ // offline after token exhaustion.
1349
+ this.reconnectWithRetry();
1350
+ }, 1000);
1351
+ this.reconnectTimer.unref?.();
1352
+ return;
1353
+ }
1354
+ this.tokenRefreshTimer = setTimeout(() => {
1355
+ this.fetchToken().catch((e2) => {
1356
+ if (this.disposed || this._reconnectId !== tokenReconnectId)
1357
+ return;
1358
+ process.stderr.write(`[QQ:${this.name}] Token refresh retry failed (attempt ${retryCount}): ${sanitizeLogText(e2 instanceof Error ? e2.message : String(e2), 200)}\n
1359
+ `);
1360
+ retry();
1361
+ });
1362
+ }, 60_000);
1363
+ this.tokenRefreshTimer.unref?.();
1364
+ };
1365
+ retry();
1366
+ });
1367
+ }, delay);
1368
+ this.tokenRefreshTimer.unref?.();
1369
+ }
1370
+ }
1371
+ stopTokenRefresh() {
1372
+ if (this.tokenRefreshTimer) {
1373
+ clearTimeout(this.tokenRefreshTimer);
1374
+ this.tokenRefreshTimer = null;
1375
+ }
1376
+ }
1377
+ // ── WebSocket Gateway ──────────────────────────────────────────
1378
+ async connectGateway() {
1379
+ if (this.disposed)
1380
+ throw new Error('Channel disposed');
1381
+ const url = await fetchGatewayUrl(this.accessToken, Boolean(this.qqConfig.sandbox));
1382
+ return new Promise((resolve, reject) => {
1383
+ this.connectReject = reject;
1384
+ this.dialGateway(url, resolve, reject);
1385
+ });
1386
+ }
1387
+ dialGateway(url, resolve, reject) {
1388
+ this.ws = new WebSocket(url);
1389
+ const dialed = this.ws;
1390
+ // 30-second READY timeout — if the gateway never sends READY,
1391
+ // close the connection so connect() rejects instead of hanging.
1392
+ this.readyTimeout = setTimeout(() => {
1393
+ if (this.ws !== dialed)
1394
+ return;
1395
+ process.stderr.write(`[QQ:${this.name}] READY timeout after 30s, closing\n`);
1396
+ this.ws?.close(4002, 'READY timeout');
1397
+ reject(new Error(`[QQ:${this.name}] READY timeout after 30s`));
1398
+ }, 30_000);
1399
+ this.readyTimeout.unref?.();
1400
+ this.ws.on('open', () => {
1401
+ process.stderr.write(`[QQ:${this.name}] WebSocket connected\n`);
1402
+ });
1403
+ this.ws.on('message', (data) => {
1404
+ try {
1405
+ const msg = JSON.parse(data.toString());
1406
+ this.handleGatewayMessage(msg, resolve);
1407
+ }
1408
+ catch (e) {
1409
+ process.stderr.write(`[QQ:${this.name}] Malformed gateway message: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`);
1410
+ }
1411
+ });
1412
+ this.ws.on('close', (code) => {
1413
+ if (this.ws !== dialed)
1414
+ return;
1415
+ process.stderr.write(`[QQ:${this.name}] WebSocket closed (code=${code})\n`);
1416
+ if (this.readyTimeout) {
1417
+ clearTimeout(this.readyTimeout);
1418
+ this.readyTimeout = null;
1419
+ }
1420
+ this.stopHeartbeat();
1421
+ this.ws = null;
1422
+ this._ready = false;
1423
+ const shouldReconnect = this.serverRequestedReconnect ||
1424
+ (code !== 1000 &&
1425
+ (this.maxReconnectAttempts <= 0 ||
1426
+ this.reconnectAttempts < this.maxReconnectAttempts));
1427
+ this.serverRequestedReconnect = false;
1428
+ // Non-1000 close codes (e.g. 4009) imply the server-side session is
1429
+ // gone; skip the RESUME attempt and go straight to IDENTIFY.
1430
+ if (code !== 1000 && code !== 4000) {
1431
+ this.tryResume = false;
1432
+ this.flushQQState();
1433
+ this.coldStart = true;
1434
+ }
1435
+ if (shouldReconnect && this.connectReject) {
1436
+ this.connectReject(new Error(`WebSocket closed before READY (code=${code})`));
1437
+ this.connectReject = null;
1438
+ }
1439
+ else if (shouldReconnect) {
1440
+ const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
1441
+ process.stderr.write(`[QQ:${this.name}] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}${this.maxReconnectAttempts > 0 ? `/${this.maxReconnectAttempts}` : ''})\n`);
1442
+ if (!this.isReconnecting) {
1443
+ this.reconnectTimer = setTimeout(() => this.reconnectWithRetry(), delay);
1444
+ this.reconnectTimer.unref();
1445
+ }
1446
+ }
1447
+ else if (this.maxReconnectAttempts > 0 &&
1448
+ this.reconnectAttempts >= this.maxReconnectAttempts) {
1449
+ process.stderr.write(`[QQ:${this.name}] FATAL: reconnect exhausted after ${this.maxReconnectAttempts} attempts. Bot is offline until daemon restart.\n`);
1450
+ if (this.connectReject) {
1451
+ this.connectReject(new Error(`WebSocket closed (max reconnect attempts, code=${code})`));
1452
+ this.connectReject = null;
1453
+ }
1454
+ }
1455
+ else {
1456
+ if (this.connectReject) {
1457
+ this.connectReject(new Error(`WebSocket closed before READY (code=${code})`));
1458
+ this.connectReject = null;
1459
+ }
1460
+ }
1461
+ });
1462
+ this.ws.on('error', (e) => {
1463
+ process.stderr.write(`[QQ:${this.name}] WebSocket error: ${sanitizeLogText(e.message, 200)}\n`);
1464
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1465
+ reject(e);
1466
+ }
1467
+ });
1468
+ }
1469
+ /**
1470
+ * Finalize READY state across cold-start and warm-reconnect paths.
1471
+ * Extracted to eliminate triplication in the READY handler.
1472
+ */
1473
+ finalizeReady() {
1474
+ if (!this.ws || this.disposed)
1475
+ return;
1476
+ this._ready = true;
1477
+ this.reconnectAttempts = 0;
1478
+ this.isReconnecting = false;
1479
+ this.coldStart = false;
1480
+ this.attachCronHandler();
1481
+ }
1482
+ handleGatewayMessage(msg, onReady) {
1483
+ const op = msg['op'];
1484
+ switch (op) {
1485
+ case OpCode.HELLO: {
1486
+ this.heartbeatInterval = Math.max(msg['d']?.['heartbeat_interval'] || 45000, 5000);
1487
+ this.sendIdentify();
1488
+ break;
1489
+ }
1490
+ case OpCode.DISPATCH: {
1491
+ const t = msg['t'];
1492
+ const s = msg['s'];
1493
+ if (s !== undefined)
1494
+ this.seq = s;
1495
+ if (t === 'READY') {
1496
+ this.reconnectAttempts = 0;
1497
+ this.isReconnecting = false;
1498
+ if (this.readyTimeout) {
1499
+ clearTimeout(this.readyTimeout);
1500
+ this.readyTimeout = null;
1501
+ }
1502
+ this.sessionId =
1503
+ msg['d']?.['session_id'] || '';
1504
+ this.tryResume = true;
1505
+ this.connectReject = null;
1506
+ this.startHeartbeat();
1507
+ if (this.coldStart) {
1508
+ this.restoreGlobalSessions();
1509
+ if (!this.restoreQQState()) {
1510
+ process.stderr.write(`[QQ:${this.name}] WARNING: QQ state restore failed — routing maps are empty, group messages may be misrouted\n`);
1511
+ }
1512
+ this.router
1513
+ .restoreSessions()
1514
+ .then(() => {
1515
+ this.fixRestoredSessions();
1516
+ const all = this.router.getAll?.();
1517
+ const count = all?.length ?? 0;
1518
+ process.stderr.write(`[QQ:${this.name}] Ready (${count} sessions)\n`);
1519
+ this.finalizeReady();
1520
+ this._checkGroupAllPolicyRequireMention();
1521
+ onReady();
1522
+ })
1523
+ .catch(() => {
1524
+ this.fixRestoredSessions();
1525
+ process.stderr.write(`[QQ:${this.name}] WARNING: router session restore failed — cron messages will be dropped until sessions re-establish\n`);
1526
+ this.finalizeReady();
1527
+ this._checkGroupAllPolicyRequireMention();
1528
+ onReady();
1529
+ });
1530
+ }
1531
+ else {
1532
+ process.stderr.write(`[QQ:${this.name}] Ready (warm reconnect, skipping state restore)\n`);
1533
+ this.finalizeReady();
1534
+ this._checkGroupAllPolicyRequireMention();
1535
+ onReady();
1536
+ }
1537
+ }
1538
+ else if (t === 'C2C_MESSAGE_CREATE') {
1539
+ this.handleC2C(msg['d']);
1540
+ }
1541
+ else if (t === 'GROUP_AT_MESSAGE_CREATE') {
1542
+ this.handleGroup(msg['d']);
1543
+ }
1544
+ else if (t === 'GROUP_MESSAGE_CREATE') {
1545
+ this.handleGroupAll(msg['d']);
1546
+ }
1547
+ else if (t === 'GROUP_ADD_ROBOT') {
1548
+ this.handleGroupAddRobot(msg['d']);
1549
+ }
1550
+ else if (t === 'GROUP_DEL_ROBOT') {
1551
+ this.handleGroupDelRobot(msg['d']);
1552
+ }
1553
+ else if (t === 'GROUP_MSG_REJECT') {
1554
+ this.handleGroupMsgToggle(msg['d'], false);
1555
+ }
1556
+ else if (t === 'GROUP_MSG_RECEIVE') {
1557
+ this.handleGroupMsgToggle(msg['d'], true);
1558
+ }
1559
+ else if (t === 'RESUMED') {
1560
+ // RESUME success — the process did NOT restart, all in-memory
1561
+ // session state, QQ routing state, and global sessions.json are
1562
+ // still intact. Calling restoreSessions() would drop and re-attach
1563
+ // every session, aborting in-flight LLM prompts.
1564
+ if (this.readyTimeout) {
1565
+ clearTimeout(this.readyTimeout);
1566
+ this.readyTimeout = null;
1567
+ }
1568
+ this.connectReject = null;
1569
+ this.finalizeReady();
1570
+ this.startHeartbeat();
1571
+ onReady();
1572
+ }
1573
+ break;
1574
+ }
1575
+ case OpCode.HEARTBEAT_ACK:
1576
+ this.lastHeartbeatAck = Date.now();
1577
+ break;
1578
+ case OpCode.RECONNECT:
1579
+ this.serverRequestedReconnect = true;
1580
+ this.ws?.close(4000);
1581
+ break;
1582
+ case OpCode.INVALID_SESSION:
1583
+ process.stderr.write(`[QQ:${this.name}] Server sent INVALID_SESSION, falling back to IDENTIFY\n`);
1584
+ this.tryResume = false;
1585
+ // Cancel any pending debounced save to prevent a TOCTOU race
1586
+ // between saveQQState and the coldStart restore on the next READY.
1587
+ if (this.saveTimer) {
1588
+ clearTimeout(this.saveTimer);
1589
+ this.saveTimer = null;
1590
+ }
1591
+ // Flush state first to persist any debounced updates before
1592
+ // coldStart=true triggers a full restore on the next READY.
1593
+ this.flushQQState();
1594
+ // Mark not ready to prevent concurrent processors from calling
1595
+ // saveQQState() during the INVALID_SESSION recovery window.
1596
+ this._ready = false;
1597
+ // Trigger full state restore on the next READY — the gateway
1598
+ // assigned a new session_id, so in-memory routing state
1599
+ // (chatTypeMap, replyMsgId, msgSeqMap) must be reloaded.
1600
+ this.coldStart = true;
1601
+ // Note: intentionally NOT refreshing token here. The server's
1602
+ // INVALID_SESSION is more likely from gateway-side session reset
1603
+ // than from token expiry. If the token IS expired, the re-IDENTIFY
1604
+ // will fail with a WS close, and the reconnect path will refresh
1605
+ // the token before the next attempt.
1606
+ this.sendIdentify();
1607
+ // Guard the re-IDENTIFY READY with a fresh timeout. The initial
1608
+ // readyTimeout was cleared by the first READY handler; without this,
1609
+ // an INVALID_SESSION re-IDENTIFY that never gets a response will
1610
+ // hang forever with no timeout to trigger a reconnect.
1611
+ if (this.readyTimeout) {
1612
+ clearTimeout(this.readyTimeout);
1613
+ this.readyTimeout = null;
1614
+ }
1615
+ this.readyTimeout = setTimeout(() => {
1616
+ if (this.ws &&
1617
+ (this.ws.readyState === WebSocket.OPEN ||
1618
+ this.ws.readyState === WebSocket.CONNECTING)) {
1619
+ this.ws.close(4002);
1620
+ if (this.connectReject) {
1621
+ this.connectReject(new Error('Timed out waiting for READY'));
1622
+ this.connectReject = null;
1623
+ }
1624
+ }
1625
+ }, 30_000);
1626
+ this.readyTimeout.unref?.();
1627
+ break;
1628
+ default:
1629
+ break;
1630
+ }
1631
+ }
1632
+ sendIdentify() {
1633
+ if (!this.ws)
1634
+ return;
1635
+ if (this.tryResume && this.sessionId) {
1636
+ process.stderr.write(`[QQ:${this.name}] Sending RESUME (session: ${this.sessionId})\n`);
1637
+ this.ws.send(JSON.stringify({
1638
+ op: OpCode.RESUME,
1639
+ d: {
1640
+ token: `QQBot ${this.accessToken}`,
1641
+ session_id: this.sessionId,
1642
+ seq: this.seq,
1643
+ },
1644
+ }));
1645
+ return;
1646
+ }
1647
+ // Include GROUP_MESSAGE intent when groupAllPolicy requires it
1648
+ const needsGroupMsg = this.qqConfig.groupAllPolicy === 'keyword' ||
1649
+ this.qqConfig.groupAllPolicy === 'all' ||
1650
+ this.qqConfig.groupAllPolicy === 'log';
1651
+ this.ws.send(JSON.stringify({
1652
+ op: OpCode.IDENTIFY,
1653
+ d: {
1654
+ token: `QQBot ${this.accessToken}`,
1655
+ intents: Intent.C2C_MESSAGE |
1656
+ Intent.GROUP_AT_MESSAGE |
1657
+ (needsGroupMsg ? Intent.GROUP_MESSAGE : 0),
1658
+ shard: [0, 1],
1659
+ properties: {},
1660
+ },
1661
+ }));
1662
+ }
1663
+ /**
1664
+ * Reconnect loop with retry on gateway fetch failures.
1665
+ * Refreshes token before each attempt, and retries GW HTTP failures
1666
+ * with exponential backoff. Keeps retrying until success.
1667
+ */
1668
+ async reconnectWithRetry() {
1669
+ if (this.disposed)
1670
+ return;
1671
+ if (this.isReconnecting)
1672
+ return;
1673
+ this.isReconnecting = true;
1674
+ try {
1675
+ const myReconnectId = this._reconnectId;
1676
+ const maxGwRetries = this.qqConfig.maxGwRetries ?? 5;
1677
+ const unlimitedRetries = maxGwRetries <= 0;
1678
+ for (let attempt = 0; unlimitedRetries || attempt < maxGwRetries; attempt++) {
1679
+ if (this.disposed || this._reconnectId !== myReconnectId)
1680
+ return;
1681
+ this.reconnectAttempts++;
1682
+ if (this.maxReconnectAttempts > 0 &&
1683
+ this.reconnectAttempts >= this.maxReconnectAttempts) {
1684
+ process.stderr.write(`[QQ:${this.name}] RC: reconnect attempts exhausted, giving up\n`);
1685
+ return;
1686
+ }
1687
+ try {
1688
+ try {
1689
+ await this.fetchToken();
1690
+ }
1691
+ catch {
1692
+ process.stderr.write(`[QQ:${this.name}] RC: token refresh failed, retrying...\n`);
1693
+ await this.sleep(2000);
1694
+ if (this.disposed)
1695
+ return;
1696
+ continue;
1697
+ }
1698
+ await this.connectGateway();
1699
+ this.startReplyMsgIdCleanup();
1700
+ return;
1701
+ }
1702
+ catch (e) {
1703
+ const msg = e instanceof Error ? e.message : String(e);
1704
+ const backoff = Math.min(1000 * 2 ** (attempt + 1), 30000);
1705
+ process.stderr.write(`[QQ:${this.name}] RC: ${sanitizeLogText(msg, 200)} (retry in ${backoff}ms, attempt ${attempt + 1}${unlimitedRetries ? '' : `/${maxGwRetries}`})\n`);
1706
+ if (unlimitedRetries || attempt < maxGwRetries - 1)
1707
+ await this.sleep(backoff);
1708
+ }
1709
+ }
1710
+ process.stderr.write(`[QQ:${this.name}] RC: exhausted ${unlimitedRetries ? '∞' : maxGwRetries} reconnect retries, will retry in 60s\n`);
1711
+ this.tryResume = false;
1712
+ }
1713
+ finally {
1714
+ this.isReconnecting = false;
1715
+ }
1716
+ this.reconnectTimer = setTimeout(() => this.reconnectWithRetry(), 60000);
1717
+ this.reconnectTimer.unref();
1718
+ }
1719
+ sleep(ms) {
1720
+ return new Promise((r) => {
1721
+ const t = setTimeout(r, ms);
1722
+ t.unref?.();
1723
+ });
1724
+ }
1725
+ startHeartbeat() {
1726
+ this.stopHeartbeat();
1727
+ this.lastHeartbeatAck = Date.now();
1728
+ this.heartbeatTimer = setInterval(() => {
1729
+ if (this.ws?.readyState !== WebSocket.OPEN)
1730
+ return;
1731
+ const elapsed = Date.now() - this.lastHeartbeatAck;
1732
+ if (elapsed > this.heartbeatInterval * 2) {
1733
+ process.stderr.write(`[QQ:${this.name}] Heartbeat ACK timeout (${elapsed}ms), forcing reconnect\n`);
1734
+ this.ws?.close(4001);
1735
+ return;
1736
+ }
1737
+ this.ws.send(JSON.stringify({ op: OpCode.HEARTBEAT, d: this.seq }));
1738
+ }, this.heartbeatInterval);
1739
+ this.heartbeatTimer.unref();
1740
+ }
1741
+ stopHeartbeat() {
1742
+ if (this.heartbeatTimer) {
1743
+ clearInterval(this.heartbeatTimer);
1744
+ this.heartbeatTimer = null;
1745
+ }
1746
+ }
1747
+ // ── Bot OpenID extraction ──────────────────────────────────────
1748
+ extractBotOpenId(mentions, chatId) {
1749
+ const selfMention = mentions?.find((m) => m.is_you);
1750
+ if (!selfMention)
1751
+ return '';
1752
+ const botOpenId = selfMention.member_openid || selfMention.id || '';
1753
+ if (!/^[A-F0-9]{32}$/i.test(botOpenId)) {
1754
+ process.stderr.write(`[QQ:${this.name}] Invalid botOpenId format: ${sanitizeLogText(botOpenId, 64)}\n`);
1755
+ return '';
1756
+ }
1757
+ if (chatId) {
1758
+ this.botOpenIdByGroup.set(chatId, botOpenId);
1759
+ this.saveQQState();
1760
+ }
1761
+ return botOpenId;
1762
+ }
1763
+ // ── Message Handlers ───────────────────────────────────────────
1764
+ /** Check if a message ID was already processed (reconnect replay dedup). */
1765
+ isDuplicate(eventId) {
1766
+ if (this.seenMessages.has(eventId))
1767
+ return true;
1768
+ const now = Date.now();
1769
+ this.seenMessages.set(eventId, now);
1770
+ if (!this.seenCleanupTimer) {
1771
+ this.seenCleanupTimer = setInterval(() => {
1772
+ const cutoff = Date.now() - QQChannel.REPLY_MSG_ID_TTL_MS;
1773
+ for (const [id, ts] of this.seenMessages) {
1774
+ if (ts < cutoff)
1775
+ this.seenMessages.delete(id);
1776
+ }
1777
+ if (this.seenMessages.size === 0) {
1778
+ clearInterval(this.seenCleanupTimer);
1779
+ this.seenCleanupTimer = null;
1780
+ }
1781
+ }, 60_000).unref();
1782
+ }
1783
+ return false;
1784
+ }
1785
+ /**
1786
+ * Extract common group-message fields shared by handleGroup and handleGroupAll.
1787
+ * Returns null when the message has no meaningful text after @-tag stripping.
1788
+ */
1789
+ prepareGroupMessage(event, chatId, { forceAtMention } = {}) {
1790
+ const senderName = event.author?.username ||
1791
+ event.author?.id ||
1792
+ event.author?.member_openid ||
1793
+ 'QQ User';
1794
+ const safeName = sanitizeSenderName(senderName);
1795
+ const senderOpenId = event.author?.member_openid || event.author?.user_openid || '';
1796
+ const content = (event.content || '').trim();
1797
+ const cleanText = content.replace(/<@[^>]{1,64}>/g, '').trim();
1798
+ // Strip trusted tags that could be forged by users
1799
+ const safeContent = content
1800
+ .replace(/\[atMention=[^\]]*]/g, '')
1801
+ .replace(/\[botOpenId:[^\]]*]/g, '')
1802
+ .replace(/\[bot]/g, '');
1803
+ const safeCleanText = cleanText
1804
+ .replace(/\[atMention=[^\]]*]/g, '')
1805
+ .replace(/\[botOpenId:[^\]]*]/g, '')
1806
+ .replace(/\[bot]/g, '')
1807
+ .trim();
1808
+ const isAtBot = event.mentions?.some((m) => m.is_you) ?? false;
1809
+ // Extract bot's own OPENID from mentions (per-group) — must come
1810
+ // BEFORE the cleanText guard so pure @-bot messages still populate
1811
+ // the per-group OPENID cache.
1812
+ if (isAtBot && !this.botOpenIdByGroup.has(chatId)) {
1813
+ this.extractBotOpenId(event.mentions, chatId);
1814
+ }
1815
+ if (!cleanText)
1816
+ return null;
1817
+ const effectiveIsAtBot = forceAtMention ?? isAtBot;
1818
+ const isSlash = effectiveIsAtBot && safeCleanText.startsWith('/');
1819
+ // Deliberately NOT hard-blocking bot messages — QQ Bot API may deliver
1820
+ // self-echoes or other bot messages. Instead, tag with [bot] prefix so the
1821
+ // model can judge relevance and decide whether to respond. Hard-blocking
1822
+ // would prevent intentional bot-to-bot interactions that the operator
1823
+ // explicitly configures. The [bot] prefix gives the model enough context
1824
+ // to ignore irrelevant bot traffic.
1825
+ // NOTE: Both callers (handleGroup, handleGroupAll) guard against bot
1826
+ // messages before reaching prepareGroupMessage, so isBot is always false
1827
+ // here. The check is retained as defense-in-depth in case a future
1828
+ // caller skips the guard.
1829
+ const groupBotOpenId = this.botOpenIdByGroup.get(chatId);
1830
+ const openIdSuffix = this.qqConfig.allowMention !== false && groupBotOpenId
1831
+ ? ` [botOpenId:${groupBotOpenId}]`
1832
+ : '';
1833
+ const suffixFromBotOpenId = this.qqConfig.allowMention !== false && groupBotOpenId
1834
+ ? `\n机器人 OPENID: ${groupBotOpenId}`
1835
+ : '';
1836
+ const text = isSlash
1837
+ ? sanitizePromptText(safeCleanText)
1838
+ : `[atMention=${effectiveIsAtBot}]${openIdSuffix} [${safeName}${senderOpenId && /^[A-F0-9]+$/i.test(senderOpenId) ? `(${senderOpenId.slice(0, 8)}\u2026)` : ''}]: ${sanitizePromptText(this.qqConfig.allowMention !== false ? safeContent : safeCleanText)}${suffixFromBotOpenId}`;
1839
+ return {
1840
+ isAtBot: effectiveIsAtBot,
1841
+ isSlash,
1842
+ safeName,
1843
+ cleanText,
1844
+ text,
1845
+ senderName,
1846
+ };
1847
+ }
1848
+ handleC2C(event) {
1849
+ if (this.isDuplicate(event.id))
1850
+ return;
1851
+ if (!event.content?.trim())
1852
+ return;
1853
+ if (!event.author) {
1854
+ process.stderr.write(`[QQ:${this.name}] C2C message dropped: missing author\n`);
1855
+ return;
1856
+ }
1857
+ if (event.author.bot) {
1858
+ process.stderr.write(`[QQ:${this.name}] Bot C2C message dropped\n`);
1859
+ return;
1860
+ }
1861
+ const chatId = event.author.user_openid || event.author.id;
1862
+ if (!chatId) {
1863
+ process.stderr.write(`[QQ:${this.name}] C2C message dropped: no chatId for author\n`);
1864
+ return;
1865
+ }
1866
+ if (!isValidChatId(chatId)) {
1867
+ process.stderr.write(`[QQ:${this.name}] C2C message dropped: invalid chatId (length=${chatId.length})\n`);
1868
+ return;
1869
+ }
1870
+ this.chatTypeMap.set(chatId, 'c2c');
1871
+ this.setReplyMsgId(chatId, event.id);
1872
+ const senderName = event.author.username || event.author.id || 'QQ User';
1873
+ const safeName = sanitizeSenderName(senderName);
1874
+ const cleanText = event.content.trim();
1875
+ // Strip system-reserved tags that could be forged by users
1876
+ const safeContent = cleanText
1877
+ .replace(/\[atMention=[^\]]*]/g, '')
1878
+ .replace(/\[botOpenId:[^\]]*]/g, '')
1879
+ .replace(/\[bot]/g, '');
1880
+ const isSlash = safeContent.startsWith('/');
1881
+ const text = isSlash
1882
+ ? sanitizePromptText(safeContent)
1883
+ : `[atMention=true] [${safeName}]: ${sanitizePromptText(safeContent)}`;
1884
+ this.handleInbound({
1885
+ channelName: this.name,
1886
+ senderId: chatId,
1887
+ senderName,
1888
+ chatId,
1889
+ text,
1890
+ messageId: event.id,
1891
+ isGroup: false,
1892
+ isMentioned: true,
1893
+ isReplyToBot: false,
1894
+ ...(isSlash ? {} : { alreadyPrefixed: true }),
1895
+ }).catch((e) => process.stderr.write(`[QQ:${this.name}] C2C handler error: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`));
1896
+ }
1897
+ handleGroup(event) {
1898
+ if (!event.group_openid) {
1899
+ process.stderr.write(`[QQ:${this.name}] Group message dropped: missing group_openid\n`);
1900
+ return;
1901
+ }
1902
+ if (!event.author) {
1903
+ process.stderr.write(`[QQ:${this.name}] Group message dropped: missing author\n`);
1904
+ return;
1905
+ }
1906
+ if (event.author.bot) {
1907
+ process.stderr.write(`[QQ:${this.name}] Bot message dropped in group ${sanitizeLogText(event.group_openid, 64)}\n`);
1908
+ return;
1909
+ }
1910
+ const chatId = event.group_openid;
1911
+ if (!isValidChatId(chatId)) {
1912
+ process.stderr.write(`[QQ:${this.name}] Group message dropped: invalid group_openid\n`);
1913
+ return;
1914
+ }
1915
+ const isNewGroup = !this.chatTypeMap.has(chatId);
1916
+ this.chatTypeMap.set(chatId, 'group');
1917
+ if (isNewGroup)
1918
+ this.saveQQState();
1919
+ // Deduplicate after prepareGroupMessage so side effects
1920
+ // (extractBotOpenId) always run, even for replayed duplicates.
1921
+ // Only skip handleInbound on duplicates — this prevents silent
1922
+ // drops when GROUP_MESSAGE_CREATE fires before GROUP_AT_MESSAGE_CREATE
1923
+ // for the same message.
1924
+ const result = this.prepareGroupMessage(event, chatId, {
1925
+ forceAtMention: true,
1926
+ });
1927
+ if (!result)
1928
+ return;
1929
+ const { isSlash, text, senderName, safeName, cleanText } = result;
1930
+ // Deduplicate before handleInbound — prepareGroupMessage already ran
1931
+ // so side effects (extractBotOpenId) are applied regardless of dedup.
1932
+ if (this.isDuplicate(event.id))
1933
+ return;
1934
+ if (isSlash) {
1935
+ process.stderr.write(`[QQ:${this.name}] Slash cmd from ${sanitizeLogText(safeName, 64)} (${sanitizeLogText(chatId, 64)}): ${sanitizeLogText(cleanText.split(/\s/)[0], 64)}\n`);
1936
+ }
1937
+ // GROUP_AT_MESSAGE_CREATE always has finalIsAtBot=true, so @-bot
1938
+ // messages are always delivered. Log when active messages are disabled.
1939
+ if (this.groupActiveMsgEnabled.get(chatId) === false) {
1940
+ process.stderr.write(`[QQ:${this.name}] handleGroup: active messages disabled but @-bot allowed through (passive)\n`);
1941
+ }
1942
+ const senderId = event.author.user_openid || event.author.id || event.author.member_openid;
1943
+ if (!senderId) {
1944
+ process.stderr.write(`[QQ:${this.name}] Group message dropped: no senderId for author\n`);
1945
+ return;
1946
+ }
1947
+ this.setReplyMsgId(chatId, event.id);
1948
+ this.handleInbound({
1949
+ channelName: this.name,
1950
+ senderId,
1951
+ senderName,
1952
+ chatId,
1953
+ text,
1954
+ messageId: event.id,
1955
+ isGroup: true,
1956
+ isMentioned: true,
1957
+ isReplyToBot: true,
1958
+ ...(isSlash ? {} : { alreadyPrefixed: true }),
1959
+ }).catch((e) => process.stderr.write(`[QQ:${this.name}] Group handler error: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`));
1960
+ }
1961
+ handleGroupAll(event) {
1962
+ if (!event.group_openid) {
1963
+ process.stderr.write(`[QQ:${this.name}] Group all-message dropped: missing group_openid\n`);
1964
+ return;
1965
+ }
1966
+ if (!event.author) {
1967
+ process.stderr.write(`[QQ:${this.name}] Group all-message dropped: missing author\n`);
1968
+ return;
1969
+ }
1970
+ if (event.author.bot) {
1971
+ process.stderr.write(`[QQ:${this.name}] Bot message dropped in group ${sanitizeLogText(event.group_openid, 64)}\n`);
1972
+ return;
1973
+ }
1974
+ const chatId = event.group_openid;
1975
+ if (!isValidChatId(chatId)) {
1976
+ process.stderr.write(`[QQ:${this.name}] Group all-message dropped: invalid group_openid\n`);
1977
+ return;
1978
+ }
1979
+ const isNewGroup = !this.chatTypeMap.has(chatId);
1980
+ this.chatTypeMap.set(chatId, 'group');
1981
+ if (isNewGroup)
1982
+ this.saveQQState();
1983
+ const result = this.prepareGroupMessage(event, chatId);
1984
+ if (!result)
1985
+ return;
1986
+ const { isSlash, text, senderName, isAtBot, safeName, cleanText } = result;
1987
+ // @-bot messages always pass through (passive reply).
1988
+ // Non-@-bot messages are subject to active-message and keyword policies.
1989
+ if (!isAtBot) {
1990
+ if (this.groupActiveMsgEnabled.get(chatId) === false) {
1991
+ process.stderr.write(`[QQ:${this.name}] handleGroupAll blocked: active messages disabled for ${sanitizeLogText(chatId, 64)}\n`);
1992
+ return;
1993
+ }
1994
+ const rawPolicy = this.qqConfig.groupAllPolicy;
1995
+ const policy = rawPolicy === 'keyword' || rawPolicy === 'all' ? rawPolicy : 'log';
1996
+ if (policy === 'log') {
1997
+ process.stderr.write(`[QQ:${this.name}] Group ${sanitizeLogText(chatId, 64)}: log policy — message from ${sanitizeLogText(senderName, 64)} not forwarded\n`);
1998
+ return;
1999
+ }
2000
+ if (policy === 'keyword') {
2001
+ if (!this._keywordTriggerCache) {
2002
+ this._keywordTriggerCache = (this.qqConfig.keywordTriggers ?? [])
2003
+ .filter((kw) => kw.length > 0)
2004
+ .map((kw) => {
2005
+ const normalized = kw.normalize('NFC');
2006
+ const escaped = normalized.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2007
+ const firstIsAscii = /^[A-Za-z0-9_]/.test(normalized);
2008
+ const lastIsAscii = /[A-Za-z0-9_]$/.test(normalized);
2009
+ const lb = firstIsAscii ? '(?:^|[^\\w])' : '';
2010
+ const la = lastIsAscii ? '(?:[^\\w]|$)' : '';
2011
+ return new RegExp(`${lb}${escaped}${la}`, 'i');
2012
+ });
2013
+ }
2014
+ if (this._keywordTriggerCache.length === 0) {
2015
+ process.stderr.write(`[QQ:${this.name}] Group ${sanitizeLogText(chatId, 64)}: keyword policy — no keywords configured, message from ${sanitizeLogText(senderName, 64)} not forwarded\n`);
2016
+ return;
2017
+ }
2018
+ const keywordText = result.cleanText.normalize('NFC');
2019
+ const matched = this._keywordTriggerCache.some((re) => re.test(keywordText));
2020
+ if (!matched) {
2021
+ const now = Date.now();
2022
+ const lastLog = this._lastKeywordNoMatchLog.get(chatId) ?? 0;
2023
+ if (now - lastLog >= 60000) {
2024
+ this._lastKeywordNoMatchLog.set(chatId, now);
2025
+ process.stderr.write(`[QQ:${this.name}] Group ${sanitizeLogText(chatId, 64)}: keyword policy — no match for message from ${sanitizeLogText(senderName, 64)}\n`);
2026
+ }
2027
+ return;
2028
+ }
2029
+ }
2030
+ }
2031
+ else if (this.groupActiveMsgEnabled.get(chatId) === false) {
2032
+ process.stderr.write(`[QQ:${this.name}] handleGroupAll: @-bot message allowed through (passive) despite active messages disabled for ${sanitizeLogText(chatId, 64)}\n`);
2033
+ }
2034
+ // isDuplicate handles reconnect replay protection (same event.id).
2035
+ if (this.isDuplicate(event.id))
2036
+ return;
2037
+ if (isSlash) {
2038
+ process.stderr.write(`[QQ:${this.name}] Slash cmd from ${sanitizeLogText(safeName, 64)} (${sanitizeLogText(chatId, 64)}): ${sanitizeLogText(cleanText.split(/\s/)[0], 64)}\n`);
2039
+ }
2040
+ // Non-@-bot messages pass isMentioned:false, which causes GroupGate.requireMention
2041
+ // to silently drop them before they reach the LLM. This is by core design:
2042
+ // non-@-bot group messages should flow through cron/log only, not trigger AI.
2043
+ // Thread #17's fix (setting isMentioned=true for non-@-bot keyword/all matches)
2044
+ // was intentionally reverted — it violated principle #1.
2045
+ // Users must set requireMention: false in group config
2046
+ // (e.g., `groups: { '*': { requireMention: false } }`) to allow
2047
+ // non-@-bot keyword/all messages to reach the AI.
2048
+ const senderId = event.author.user_openid || event.author.id || event.author.member_openid;
2049
+ if (!senderId) {
2050
+ process.stderr.write(`[QQ:${this.name}] Group all-message dropped: no senderId for author\n`);
2051
+ return;
2052
+ }
2053
+ // Set replyMsgId for all messages that pass the policy gate,
2054
+ // not just @-bot ones.
2055
+ this.setReplyMsgId(chatId, event.id);
2056
+ this.handleInbound({
2057
+ channelName: this.name,
2058
+ chatId,
2059
+ text,
2060
+ senderId,
2061
+ senderName,
2062
+ messageId: event.id,
2063
+ isGroup: true,
2064
+ isMentioned: isAtBot,
2065
+ isReplyToBot: isAtBot,
2066
+ ...(isSlash ? {} : { alreadyPrefixed: true }),
2067
+ }).catch((e) => {
2068
+ process.stderr.write(`[QQ:${this.name}] handleGroupAll error: ${sanitizeLogText(e instanceof Error ? e.message : String(e), 200)}\n`);
2069
+ });
2070
+ }
2071
+ // ── Group management events ────────────────────────────────────
2072
+ handleGroupAddRobot(event) {
2073
+ const groupId = event.group_openid;
2074
+ if (!groupId) {
2075
+ process.stderr.write(`[QQ:${this.name}] handleGroupAddRobot: missing group_openid\n`);
2076
+ return;
2077
+ }
2078
+ if (!isValidChatId(groupId)) {
2079
+ process.stderr.write(`[QQ:${this.name}] handleGroupAddRobot: invalid group_openid (length=${groupId.length})\n`);
2080
+ return;
2081
+ }
2082
+ this.chatTypeMap.set(groupId, 'group');
2083
+ this.saveQQState();
2084
+ process.stderr.write(`[QQ:${this.name}] Added to group ${sanitizeLogText(groupId, 64)} by ${sanitizeLogText(event.op_member_openid, 64)}\n`);
2085
+ }
2086
+ handleGroupDelRobot(event) {
2087
+ const groupId = event.group_openid;
2088
+ if (!groupId) {
2089
+ process.stderr.write(`[QQ:${this.name}] handleGroupDelRobot: missing group_openid\n`);
2090
+ return;
2091
+ }
2092
+ if (!isValidChatId(groupId)) {
2093
+ process.stderr.write(`[QQ:${this.name}] handleGroupDelRobot: invalid group_openid (length=${groupId.length})\n`);
2094
+ return;
2095
+ }
2096
+ this.chatTypeMap.delete(groupId);
2097
+ this.groupActiveMsgEnabled.delete(groupId);
2098
+ // msgSeqMap is keyed by message ID, not group_openid — get the
2099
+ // message ID from replyMsgId before deleting the reply entry.
2100
+ const replyEntry = this.replyMsgId.get(groupId);
2101
+ if (replyEntry)
2102
+ this.msgSeqMap.delete(replyEntry.msgId);
2103
+ this.replyMsgId.delete(groupId);
2104
+ this.botOpenIdByGroup.delete(groupId);
2105
+ this._lastKeywordNoMatchLog.delete(groupId);
2106
+ // Clean up cron buffers targeting this group (always, regardless of config flag)
2107
+ let cleanedCron = 0;
2108
+ for (const [sid, entry] of this.cronBuffer) {
2109
+ const state = this.streamState.get(sid);
2110
+ if (state?.chatId === groupId) {
2111
+ if (entry.timer)
2112
+ clearTimeout(entry.timer);
2113
+ this.cronBuffer.delete(sid);
2114
+ cleanedCron++;
2115
+ }
2116
+ }
2117
+ // Clean up active streamState sessions targeting this group.
2118
+ // Cancel pending idle-flush timers before deleting entries so
2119
+ // setTimeout callbacks don't fire and attempt to send to the
2120
+ // removed group.
2121
+ let cleanedStreams = 0;
2122
+ for (const [sid, state] of this.streamState) {
2123
+ if (state.chatId === groupId) {
2124
+ if (state.timer)
2125
+ clearTimeout(state.timer);
2126
+ this.flushingSessions.delete(sid);
2127
+ this.pendingStreamDelete.delete(sid);
2128
+ this.flushedSessions.delete(sid);
2129
+ this.streamState.delete(sid);
2130
+ if (this.config.sessionScope !== 'single') {
2131
+ this.onSessionDied(sid);
2132
+ }
2133
+ cleanedStreams++;
2134
+ }
2135
+ }
2136
+ this.saveQQState();
2137
+ process.stderr.write(`[QQ:${this.name}] Removed from group ${sanitizeLogText(groupId, 64)} by ${sanitizeLogText(event.op_member_openid, 64)}, cleaned ${cleanedStreams} stream(s) and ${cleanedCron} cron buffer(s)\n`);
2138
+ }
2139
+ handleGroupMsgToggle(event, enabled) {
2140
+ if (!event.group_openid) {
2141
+ process.stderr.write(`[QQ:${this.name}] Group msg toggle dropped: missing group_openid\n`);
2142
+ return;
2143
+ }
2144
+ if (!isValidChatId(event.group_openid)) {
2145
+ process.stderr.write(`[QQ:${this.name}] Group msg toggle dropped: invalid group_openid\n`);
2146
+ return;
2147
+ }
2148
+ this.groupActiveMsgEnabled.set(event.group_openid, enabled);
2149
+ this.saveQQState();
2150
+ process.stderr.write(`[QQ:${this.name}] Active msg ${enabled ? 'enabled' : 'disabled'} for group ${sanitizeLogText(event.group_openid, 64)}\n`);
2151
+ }
2152
+ }
2153
+ //# sourceMappingURL=QQChannel.js.map