@qwen-code/channel-qqbot 0.21.0-preview.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/QQChannel.d.ts +267 -0
- package/dist/QQChannel.js +2153 -0
- package/dist/QQChannel.js.map +1 -0
- package/dist/accounts.d.ts +24 -0
- package/dist/accounts.js +46 -0
- package/dist/accounts.js.map +1 -0
- package/dist/api.d.ts +41 -0
- package/dist/api.js +111 -0
- package/dist/api.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/login.d.ts +15 -0
- package/dist/login.js +25 -0
- package/dist/login.js.map +1 -0
- package/dist/types.d.ts +105 -0
- package/dist/types.js +21 -0
- package/dist/types.js.map +1 -0
- package/package.json +29 -0
|
@@ -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
|