@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.
@@ -0,0 +1,267 @@
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 } from '@qwen-code/channel-base';
15
+ import type { ChannelConfig, ChannelBaseOptions, ChannelAgentBridge, ToolCallEvent } from '@qwen-code/channel-base';
16
+ export type DeliveryErrorCode = 'RATE_LIMITED' | 'RETRY_EXHAUSTED' | 'FALLBACK_FAILED' | 'ACTIVE_MSG_DISABLED';
17
+ export declare class DeliveryError extends Error {
18
+ readonly code: DeliveryErrorCode;
19
+ constructor(code: DeliveryErrorCode, message: string);
20
+ }
21
+ /** Validate chatId to prevent SSRF when constructing URLs. */
22
+ export declare function isValidChatId(id: string): boolean;
23
+ export declare class QQChannel extends ChannelBase {
24
+ private ws;
25
+ private accessToken;
26
+ private tokenExpiresAt;
27
+ private tokenRefreshTimer;
28
+ private heartbeatTimer;
29
+ private heartbeatInterval;
30
+ private seq;
31
+ private reconnectAttempts;
32
+ private maxReconnectAttempts;
33
+ /** QQ Bot session_id from READY, used for RESUME on reconnect. */
34
+ private sessionId;
35
+ /** Whether this connection attempt should try RESUME first. */
36
+ private tryResume;
37
+ private readonly qqConfig;
38
+ /** Set when server sends RECONNECT opcode — close handler uses this to force reconnect. */
39
+ private serverRequestedReconnect;
40
+ /** Pending connect promise reject — called when WebSocket closes before READY. */
41
+ private connectReject;
42
+ /** Set to true when channel is disconnected — prevents orphaned connections. */
43
+ private disposed;
44
+ /** Deduplicate inbound messages on reconnect replay (messageId → timestamp). */
45
+ private seenMessages;
46
+ /** Cleanup timer for seenMessages TTL eviction. */
47
+ private seenCleanupTimer;
48
+ /** Timestamp of last received HEARTBEAT_ACK, for zombie-connection detection. */
49
+ private lastHeartbeatAck;
50
+ /** Debounce timer for saveQQState to avoid blocking event loop. */
51
+ private saveTimer;
52
+ /** beforeExit hook to flush state when the event loop drains naturally. Does NOT fire for SIGKILL, OOM kills, or uncaughtException. */
53
+ private beforeExitHook;
54
+ /** Timer for reconnectWithRetry fallback (unref'd so it doesn't block exit). */
55
+ private reconnectTimer;
56
+ /** 30s READY timeout to prevent hanging on gateway without response. */
57
+ private readyTimeout;
58
+ /** Guard against parallel reconnectWithRetry chains from stale close events. */
59
+ private isReconnecting;
60
+ /** Track whether a chatId is a group or C2C for correct API routing. */
61
+ private chatTypeMap;
62
+ /** Track the latest user messageId per chatId for proper reply (msg_id). */
63
+ private replyMsgId;
64
+ /** msg_seq counter per user messageId, for multi-block streaming. */
65
+ private msgSeqMap;
66
+ /** Periodic cleanup timer for expired replyMsgId entries. */
67
+ private replyMsgIdCleanupTimer;
68
+ /** 5-minute TTL for replyMsgId entries and seenMessages dedup. */
69
+ private static readonly REPLY_MSG_ID_TTL_MS;
70
+ /** Idle-flush timeout: buffer is sent after this many ms of silence. */
71
+ private static readonly IDLE_FLUSH_MS;
72
+ /** Max consecutive send failures before the stream is abandoned. */
73
+ private maxFlushRetries;
74
+ /** Retry delay for subsequent attempts (backoff beyond first retry). */
75
+ private static readonly IDLE_FLUSH_BACKOFF_MS;
76
+ /** Max buffer length before forcing an immediate flush. */
77
+ private static readonly MAX_BUFFER_LENGTH;
78
+ /** Per-group bot OPENID map for multi-group support. */
79
+ private botOpenIdByGroup;
80
+ /** Guard: set to true after first READY + session restore completes. */
81
+ private _ready;
82
+ /** Whether this process has never received READY (cold start). */
83
+ private coldStart;
84
+ /** Track per-group active message permission. */
85
+ private groupActiveMsgEnabled;
86
+ /** Lazy cache for compiled keyword trigger RegExp patterns.
87
+ * Built lazily on first access; never invalidated — keywordTriggers is not modified at runtime. */
88
+ private _keywordTriggerCache;
89
+ /** Rate-limit timestamps for keyword non-match log entries (chatId → last log ms). */
90
+ private _lastKeywordNoMatchLog;
91
+ /** Accumulation buffer for cron/non-prompt textChunk events. */
92
+ private cronBuffer;
93
+ /** Named handler for permanent textChunk listener (cron/non-prompt). */
94
+ private _cronTextHandler;
95
+ /** Gate: depth counter for cron-scheduled message flows. >0 means in-flow.
96
+ Prevents phantom cronBuffer entries when textChunk fires during normal
97
+ bridge.prompt() calls (ChannelBase has its own listener there).
98
+ Using a counter instead of a boolean supports concurrent cron flows. */
99
+ private _inCronFlow;
100
+ private cronTextHandlerAttached;
101
+ /** Path to persisted QQ routing state: chatTypeMap, replyMsgId, msgSeqMap. */
102
+ /**
103
+ * Streaming state machine with per-session buffers.
104
+ *
105
+ * Three states for each session:
106
+ * active — accumulating chunks in buffer (onResponseChunk extends timer)
107
+ * flushing — sendMessage() is in-flight (prevents parallel sends)
108
+ * idle — waiting for next chunk (timer counting down to idleFlush)
109
+ *
110
+ * Transitions:
111
+ * active → flushing: idleFlush timer fires, or onToolCall cancels timer
112
+ * flushing → idle: send settles, idle timer restarts on retry
113
+ * any → done: onResponseComplete sends remaining content
114
+ *
115
+ * Guards:
116
+ * - flushingSessions prevents concurrent sends per session
117
+ * - pendingStreamDelete defers cleanup until in-flight send resolves
118
+ * - flushedSessions tracks already-sent sessions to skip final fullText
119
+ */
120
+ private streamState;
121
+ private flushingSessions;
122
+ private pendingStreamDelete;
123
+ private _reconnectId;
124
+ private blockStreaming;
125
+ private flushedSessions;
126
+ private readonly qqStatePath;
127
+ /**
128
+ * Path to the global sessions.json managed by start.ts.
129
+ * start.ts deletes it on shutdown, so we back it up.
130
+ */
131
+ private readonly globalSessionsPath;
132
+ /** Backup of sessions.json so conversations survive daemon restarts. */
133
+ private readonly sessionsBackupPath;
134
+ constructor(name: string, config: ChannelConfig & Record<string, unknown>, bridge: ChannelAgentBridge, options?: ChannelBaseOptions);
135
+ private handleCronTextChunk;
136
+ /**
137
+ * Public gate for external cron/scheduler integration.
138
+ * Wraps a cron message flow to activate `_inCronFlow` so that
139
+ * `textChunk` events are captured into the cron accumulation buffer.
140
+ * Uses a depth counter (not boolean) so concurrent cron flows
141
+ * don't stomp each other's `_inCronFlow` state.
142
+ * Always decrements `_inCronFlow` in a `finally` block.
143
+ */
144
+ runCronFlow(fn: () => Promise<void>): Promise<void>;
145
+ /**
146
+ * Override setBridge to re-attach the permanent `_cronTextHandler`
147
+ * after bridge crash-recovery.
148
+ */
149
+ setBridge(bridge: ChannelAgentBridge): void;
150
+ connect(): Promise<void>;
151
+ sendMessage(chatId: string, text: string): Promise<void>;
152
+ /**
153
+ * Resolve API routing: handles disposed check, token refresh, chatId validation,
154
+ * sandbox detection, and C2C/group path selection. Returns null if any guard fails.
155
+ */
156
+ private resolveRoute;
157
+ disconnect(): void;
158
+ /**
159
+ * QQ Bot API V2 does not provide a typing indicator endpoint.
160
+ * ChannelBase calls these hooks to signal prompt start/end;
161
+ * they are intentionally no-ops for this channel.
162
+ */
163
+ protected onPromptStart(_chatId: string, _sessionId: string, _messageId?: string): void;
164
+ protected onPromptEnd(_chatId: string, _sessionId: string, _messageId?: string): void;
165
+ protected onResponseChunk(chatId: string, chunk: string, sessionId: string): void;
166
+ private idleFlush;
167
+ /**
168
+ * Shared send-and-track helper used by idleFlush and onToolCall.
169
+ * Encapsulates .then() (cleanup on success) and .catch() (retry/re-buffer
170
+ * on failure) logic to eliminate duplication.
171
+ */
172
+ private flushAndTrack;
173
+ onToolCall(_chatId: string, event: ToolCallEvent): void;
174
+ protected onResponseBoundary(_chatId: string, sessionId: string): void;
175
+ protected onResponseComplete(chatId: string, fullText: string, sessionId: string): Promise<void>;
176
+ onSessionDied(sessionId: string): void;
177
+ private serializeQQState;
178
+ /** Debounced state persistence with atomic write. */
179
+ private saveQQState;
180
+ /**
181
+ * Attach the permanent textChunk handler for cron/non-prompt messages
182
+ * to the current bridge. No-op if already attached or if cron is disabled.
183
+ */
184
+ private attachCronHandler;
185
+ private _checkGroupAllPolicyRequireMention;
186
+ /**
187
+ * Detach the permanent textChunk handler from the current bridge.
188
+ * No-op if not attached or if cron is disabled.
189
+ */
190
+ private detachCronHandler;
191
+ /** Flush pending state writes immediately (called on disconnect). */
192
+ private flushQQState;
193
+ /**
194
+ * Restore QQ routing state from disk.
195
+ *
196
+ * Validates all restored state extensively — type checks, length bounds,
197
+ * and sanity filters — so a corrupted file produces clean empty maps
198
+ * rather than propagating invalid data.
199
+ */
200
+ private restoreQQState;
201
+ /**
202
+ * Backup the global sessions.json before start.ts deletes it on shutdown.
203
+ * Restored on next connect so conversations survive daemon restarts.
204
+ */
205
+ private backupGlobalSessions;
206
+ private restoreGlobalSessions;
207
+ /**
208
+ * Compatibility repair for legacy restored session state where older router
209
+ * code could keep an empty session id after bridge.loadSession() failed to
210
+ * return a session_id.
211
+ *
212
+ * **Fragile**: accesses SessionRouter's private `toSession`/`toTarget`/`toCwd`
213
+ * maps via type coercion. If SessionRouter internals change, this breaks
214
+ * silently. The only signal will be cross-server conversations failing to
215
+ * restore after daemon restart — no crash, no log.
216
+ *
217
+ * Keep this while old persisted files may still exist.
218
+ */
219
+ private fixRestoredSessions;
220
+ /**
221
+ * Set replyMsgId for a chat, cleaning up the previous entry's msgSeqMap
222
+ * to prevent orphaned entries accumulating over time.
223
+ */
224
+ private setReplyMsgId;
225
+ /**
226
+ * Start periodic cleanup of expired replyMsgId entries.
227
+ * Evicts entries older than 5 minutes every 60 seconds, and cascades
228
+ * to msgSeqMap.
229
+ */
230
+ private startReplyMsgIdCleanup;
231
+ private stopReplyMsgIdCleanup;
232
+ private fetchToken;
233
+ private scheduleTokenRefresh;
234
+ private stopTokenRefresh;
235
+ private connectGateway;
236
+ private dialGateway;
237
+ /**
238
+ * Finalize READY state across cold-start and warm-reconnect paths.
239
+ * Extracted to eliminate triplication in the READY handler.
240
+ */
241
+ private finalizeReady;
242
+ private handleGatewayMessage;
243
+ private sendIdentify;
244
+ /**
245
+ * Reconnect loop with retry on gateway fetch failures.
246
+ * Refreshes token before each attempt, and retries GW HTTP failures
247
+ * with exponential backoff. Keeps retrying until success.
248
+ */
249
+ private reconnectWithRetry;
250
+ private sleep;
251
+ private startHeartbeat;
252
+ private stopHeartbeat;
253
+ private extractBotOpenId;
254
+ /** Check if a message ID was already processed (reconnect replay dedup). */
255
+ private isDuplicate;
256
+ /**
257
+ * Extract common group-message fields shared by handleGroup and handleGroupAll.
258
+ * Returns null when the message has no meaningful text after @-tag stripping.
259
+ */
260
+ private prepareGroupMessage;
261
+ private handleC2C;
262
+ private handleGroup;
263
+ private handleGroupAll;
264
+ private handleGroupAddRobot;
265
+ private handleGroupDelRobot;
266
+ private handleGroupMsgToggle;
267
+ }