| /** |
| * PR-BOT-QQ-OPERATIONAL-0 (external bot research: QQ 官方机器人 Gateway): |
| * full QQ Channel Bot lifecycle — access_token cache, Gateway WebSocket |
| * connect, identify, heartbeat, AT_MESSAGE_CREATE / DIRECT_MESSAGE_CREATE |
| * / GROUP_AT_MESSAGE_CREATE / C2C_MESSAGE_CREATE dispatch, REST send via |
| * /v2/groups/{id}/messages and /v2/users/{id}/messages, reconnect with |
| * backoff. |
| * |
| * QQ Bot Gateway protocol is the same shape as Discord (op codes 0/1/2/ |
| * 7/9/10/11, identify+heartbeat lifecycle); only the auth scheme + dispatch |
| * event names differ. Auth uses `QQBot <access_token>` (the app access |
| * token from getAppAccessToken), refreshed before expiry. |
| */ |
| |
| import { WebSocket } from 'undici'; |
| import type { BotChannelSettings } from '@maka/core'; |
| import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js'; |
| import { proxiedFetch } from './proxied-fetch.js'; |
| import type { BotPlatform, BotSendOptions, BotStatus, SendCapable } from './types.js'; |
| |
| const QQ_API = 'https://api.sgroup.qq.com'; |
| const QQ_BOT_TOKEN = 'https://bots.qq.com/app/getAppAccessToken'; |
| |
| // PUBLIC_GUILD_MESSAGES | DIRECT_MESSAGE | GUILDS — sufficient for |
| // receive of @-mentions in guild channels + DMs. |
| // 1 (GUILDS) | 4096 (DIRECT_MESSAGE) | 1<<30 (PUBLIC_GUILD_MESSAGES). |
| const QQ_INTENT_GUILDS = 1 << 0; |
| const QQ_INTENT_DIRECT_MESSAGE = 1 << 12; |
| const QQ_INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30; |
| const QQ_INTENT_PUBLIC_MESSAGES = 1 << 25; |
| export const QQ_INTENTS = |
| QQ_INTENT_GUILDS | |
| QQ_INTENT_DIRECT_MESSAGE | |
| QQ_INTENT_PUBLIC_GUILD_MESSAGES | |
| QQ_INTENT_PUBLIC_MESSAGES; |
| |
| const TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1_000; |
| const RECONNECT_DELAY_MIN_MS = 1_000; |
| const RECONNECT_DELAY_MAX_MS = 30_000; |
| const SEND_RETRY_DELAY_MIN_MS = 1_000; |
| const SEND_RETRY_DELAY_MAX_MS = 30_000; |
| |
| const OP_DISPATCH = 0; |
| const OP_HEARTBEAT = 1; |
| const OP_IDENTIFY = 2; |
| const OP_RESUME = 6; |
| const OP_RECONNECT = 7; |
| const OP_INVALID_SESSION = 9; |
| const OP_HELLO = 10; |
| const OP_HEARTBEAT_ACK = 11; |
| |
| // QQ uses these dispatch event types for message arrival. |
| const QQ_EVENT_AT_MESSAGE = 'AT_MESSAGE_CREATE'; |
| const QQ_EVENT_DIRECT_MESSAGE = 'DIRECT_MESSAGE_CREATE'; |
| const QQ_EVENT_GROUP_AT_MESSAGE = 'GROUP_AT_MESSAGE_CREATE'; |
| const QQ_EVENT_C2C_MESSAGE = 'C2C_MESSAGE_CREATE'; |
| |
| interface QQReadyPayload { |
| session_id: string; |
| user: { id: string; username?: string }; |
| } |
| |
| // QQ guild message (channel @-mention): { id, channel_id, content, author } |
| interface QQChannelMessagePayload { |
| id: string; |
| channel_id: string; |
| guild_id?: string; |
| content?: string; |
| author?: { id: string; username?: string; bot?: boolean }; |
| } |
| |
| // QQ group message: { id, group_openid, content, author: { id, member_openid } } |
| interface QQGroupMessagePayload { |
| id: string; |
| group_openid: string; |
| content?: string; |
| author?: { id?: string; member_openid?: string; user_openid?: string }; |
| } |
| |
| // QQ C2C (1-on-1) message: { id, content, author: { user_openid } } |
| interface QQC2CMessagePayload { |
| id: string; |
| content?: string; |
| author?: { user_openid?: string; id?: string }; |
| } |
| |
| export type QQCloseDecision = |
| | { kind: 'stopped' } |
| | { kind: 'fatal'; code: number } |
| | { kind: 'reconnect'; resumable: boolean }; |
| |
| const QQ_FATAL_CLOSE_CODES = new Set<number>([ |
| 4004, // authentication failed |
| 4014, // disallowed intent |
| ]); |
| |
| export function decideQQClose(code: number, explicitlyStopped: boolean): QQCloseDecision { |
| if (explicitlyStopped) return { kind: 'stopped' }; |
| if (QQ_FATAL_CLOSE_CODES.has(code)) return { kind: 'fatal', code }; |
| const resumable = code !== 1000 && code !== 1001; |
| return { kind: 'reconnect', resumable }; |
| } |
| |
| export function qqReconnectBackoffMs(attempts: number): number { |
| const exp = Math.min(2 ** attempts, RECONNECT_DELAY_MAX_MS / RECONNECT_DELAY_MIN_MS); |
| return Math.min(RECONNECT_DELAY_MIN_MS * exp, RECONNECT_DELAY_MAX_MS); |
| } |
| |
| /** |
| * Pure helper: classify a QQ HTTP send response. QQ's open-platform |
| * error scheme uses `code` in the JSON body for non-200s. 429 is |
| * the retry signal. |
| */ |
| export type QQSendClassification = |
| | { kind: 'ok'; messageId: string | null } |
| | { kind: 'retry'; delayMs: number } |
| | { kind: 'fatal'; description: string }; |
| |
| export function classifyQQSendResponse(status: number, bodyJson: unknown): QQSendClassification { |
| if (status >= 200 && status < 300) { |
| const id = |
| (bodyJson as { id?: unknown; msg_id?: unknown } | null)?.id ?? |
| (bodyJson as { msg_id?: unknown } | null)?.msg_id; |
| return { |
| kind: 'ok', |
| messageId: typeof id === 'string' || typeof id === 'number' ? String(id) : null, |
| }; |
| } |
| if (status === 429) { |
| return { |
| kind: 'retry', |
| delayMs: Math.min(Math.max(SEND_RETRY_DELAY_MIN_MS, 1_000), SEND_RETRY_DELAY_MAX_MS), |
| }; |
| } |
| const body = bodyJson as { message?: unknown; code?: unknown } | null; |
| const description = |
| (typeof body?.message === 'string' && body.message) || |
| (typeof body?.code === 'number' ? `code ${body.code}` : `HTTP ${status}`); |
| return { kind: 'fatal', description }; |
| } |
| |
| /** |
| * Pure helper: map a QQ channel AT message dispatch to BotMessageEvent. |
| */ |
| export function qqChannelMessageToEvent( |
| d: QQChannelMessagePayload, |
| receivedAt: number, |
| ): { |
| platform: 'qq'; |
| userId: string; |
| userName: string; |
| chatId: string; |
| isGroup: boolean; |
| text: string; |
| sourceMessageId: string; |
| receivedAt: number; |
| } | null { |
| if (!d?.author || d.author.bot === true) return null; |
| const userId = String(d.author.id); |
| return { |
| platform: 'qq', |
| userId, |
| userName: d.author.username ?? userId, |
| chatId: `channel:${d.channel_id}`, |
| isGroup: true, |
| text: typeof d.content === 'string' ? d.content : '', |
| sourceMessageId: String(d.id), |
| receivedAt, |
| }; |
| } |
| |
| export function qqGroupMessageToEvent( |
| d: QQGroupMessagePayload, |
| receivedAt: number, |
| ): { |
| platform: 'qq'; |
| userId: string; |
| userName: string; |
| chatId: string; |
| isGroup: boolean; |
| text: string; |
| sourceMessageId: string; |
| receivedAt: number; |
| } | null { |
| if (!d?.group_openid) return null; |
| const userId = d.author?.member_openid ?? d.author?.user_openid ?? d.author?.id ?? ''; |
| if (!userId) return null; |
| return { |
| platform: 'qq', |
| userId: String(userId), |
| userName: String(userId), |
| chatId: `group:${d.group_openid}`, |
| isGroup: true, |
| text: typeof d.content === 'string' ? d.content : '', |
| sourceMessageId: String(d.id), |
| receivedAt, |
| }; |
| } |
| |
| export function qqC2CMessageToEvent( |
| d: QQC2CMessagePayload, |
| receivedAt: number, |
| ): { |
| platform: 'qq'; |
| userId: string; |
| userName: string; |
| chatId: string; |
| isGroup: boolean; |
| text: string; |
| sourceMessageId: string; |
| receivedAt: number; |
| } | null { |
| const userId = d.author?.user_openid ?? d.author?.id ?? ''; |
| if (!userId) return null; |
| return { |
| platform: 'qq', |
| userId: String(userId), |
| userName: String(userId), |
| chatId: `c2c:${userId}`, |
| isGroup: false, |
| text: typeof d.content === 'string' ? d.content : '', |
| sourceMessageId: String(d.id), |
| receivedAt, |
| }; |
| } |
| |
| /** |
| * Pure helper: route a send to the right QQ REST endpoint based on the |
| * chatId prefix that the receive-side helpers stamp. |
| */ |
| export function pickQQSendRoute( |
| chatId: string, |
| text: string, |
| options?: BotSendOptions, |
| ): { |
| path: string; |
| body: Record<string, unknown>; |
| } | null { |
| const replyId = options?.replyToMessageId; |
| if (chatId.startsWith('channel:')) { |
| const targetId = chatId.slice('channel:'.length).trim(); |
| if (!targetId) return null; |
| return { |
| path: `/channels/${targetId}/messages`, |
| body: { |
| content: text, |
| msg_type: 0, |
| ...(replyId ? { msg_id: replyId } : {}), |
| }, |
| }; |
| } |
| if (chatId.startsWith('group:')) { |
| const targetId = chatId.slice('group:'.length).trim(); |
| if (!targetId) return null; |
| return { |
| path: `/v2/groups/${targetId}/messages`, |
| body: { |
| content: text, |
| msg_type: 0, |
| ...(replyId ? { msg_id: replyId } : {}), |
| }, |
| }; |
| } |
| if (chatId.startsWith('c2c:')) { |
| const targetId = chatId.slice('c2c:'.length).trim(); |
| if (!targetId) return null; |
| return { |
| path: `/v2/users/${targetId}/messages`, |
| body: { |
| content: text, |
| msg_type: 0, |
| ...(replyId ? { msg_id: replyId } : {}), |
| }, |
| }; |
| } |
| return null; |
| } |
| |
| export function pickQQTypingRoute(chatId: string): string | null { |
| if (!chatId.startsWith('channel:')) return null; |
| const channelId = chatId.slice('channel:'.length).trim(); |
| if (!channelId) return null; |
| return `/channels/${channelId}/typing`; |
| } |
| |
| function sleep(ms: number): Promise<void> { |
| return new Promise((resolve) => setTimeout(resolve, ms)); |
| } |
| |
| interface CachedToken { |
| value: string; |
| expiresAt: number; |
| } |
| |
| export class QQBotBridge extends BaseBotAdapter implements SendCapable { |
| private ws: WebSocket | null = null; |
| private heartbeatTimer: NodeJS.Timeout | null = null; |
| private heartbeatInterval = 41_250; |
| private heartbeatAcked = true; |
| private seq: number | null = null; |
| private sessionId: string | null = null; |
| private token: CachedToken | null = null; |
| private explicitlyStopped = false; |
| private reconnectAttempts = 0; |
| private reconnectTimer: NodeJS.Timeout | null = null; |
| |
| constructor(platform: BotPlatform, settings: BotChannelSettings) { |
| super(platform, settings); |
| } |
| |
| async start(): Promise<void> { |
| if (this.running) return; |
| if (!this.settings.enabled) { |
| this.reason = 'disabled'; |
| this.readiness = 'scaffolded'; |
| return; |
| } |
| if (!this.settings.appId?.trim() || !this.settings.appSecret?.trim()) { |
| this.reason = 'no-credentials'; |
| this.readiness = 'scaffolded'; |
| return; |
| } |
| this.explicitlyStopped = false; |
| await this.startGateway(); |
| } |
| |
| async stop(): Promise<void> { |
| this.explicitlyStopped = true; |
| this.running = false; |
| this.clearHeartbeat(); |
| this.clearReconnect(); |
| if (this.ws) { |
| try { |
| this.ws.close(1000); |
| } catch { |
| /* swallow */ |
| } |
| this.ws = null; |
| } |
| this.reason = 'stopped'; |
| this.readiness = botReadinessFromSettings(this.settings); |
| this.emitStatusChange(); |
| } |
| |
| async sendMessage( |
| chatId: string, |
| text: string, |
| options?: BotSendOptions, |
| ): Promise<string | null> { |
| if (this.platform !== 'qq' || !this.running) return null; |
| const route = pickQQSendRoute(chatId, text, options); |
| if (!route) return null; |
| const token = await this.refreshTokenIfNeeded(); |
| if (!token) return null; |
| const first = await this.performSend(route.path, route.body, token); |
| let classification = first; |
| if (first.kind === 'retry') { |
| await sleep(first.delayMs); |
| classification = await this.performSend(route.path, route.body, token); |
| } |
| if (classification.kind !== 'ok') { |
| this.readiness = this.readiness === 'operational' ? 'degraded' : 'credentials_valid'; |
| this.reason = classification.kind === 'retry' ? 'rate-limited' : classification.description; |
| this.emitStatusChange(); |
| return null; |
| } |
| this.readiness = 'operational'; |
| this.reason = undefined; |
| this.lastEventAt = Date.now(); |
| this.emitStatusChange(); |
| return classification.messageId; |
| } |
| |
| /** |
| * PR-BOT-QQ-TYPING-INDICATOR-0: parity with Telegram + Discord. |
| * QQ Channel Bot exposes POST `/channels/{channel_id}/typing` for |
| * guild channel messages only — Groups and C2C use a different |
| * messaging stack with no typing endpoint. We gate on the `channel:` |
| * chatId prefix so an unsupported target silently degrades to |
| * `false` rather than emitting a confusing 404. |
| */ |
| async sendTypingIndicator(chatId: string): Promise<boolean> { |
| if (this.platform !== 'qq' || !this.running) return false; |
| const route = pickQQTypingRoute(chatId); |
| if (!route) return false; |
| const token = await this.refreshTokenIfNeeded(); |
| if (!token) return false; |
| try { |
| const response = await proxiedFetch(`${QQ_API}${route}`, { |
| method: 'POST', |
| headers: { Authorization: `QQBot ${token}` }, |
| timeoutMs: 5_000, |
| }); |
| return response.ok; |
| } catch { |
| return false; |
| } |
| } |
| |
| protected override connectionKind(): BotStatus['connection'] { |
| return 'gateway'; |
| } |
| |
| private async performSend( |
| path: string, |
| body: Record<string, unknown>, |
| token: string, |
| ): Promise<QQSendClassification> { |
| try { |
| const response = await proxiedFetch(`${QQ_API}${path}`, { |
| method: 'POST', |
| headers: { |
| Authorization: `QQBot ${token}`, |
| 'Content-Type': 'application/json', |
| }, |
| body: JSON.stringify(body), |
| timeoutMs: 10_000, |
| }); |
| const json = await response.json().catch(() => null); |
| return classifyQQSendResponse(response.status, json); |
| } catch (error) { |
| return { kind: 'fatal', description: error instanceof Error ? error.message : String(error) }; |
| } |
| } |
| |
| private async refreshTokenIfNeeded(): Promise<string | null> { |
| const now = Date.now(); |
| if (this.token && this.token.expiresAt - TOKEN_REFRESH_SKEW_MS > now) { |
| return this.token.value; |
| } |
| const appId = this.settings.appId?.trim() ?? ''; |
| const clientSecret = this.settings.appSecret?.trim() ?? ''; |
| if (!appId || !clientSecret) return null; |
| try { |
| const response = await proxiedFetch(QQ_BOT_TOKEN, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ appId, clientSecret }), |
| timeoutMs: 10_000, |
| }); |
| const json = (await response.json().catch(() => null)) as { |
| access_token?: unknown; |
| expires_in?: unknown; |
| } | null; |
| if (!response.ok || typeof json?.access_token !== 'string') { |
| this.reason = `getAppAccessToken-${response.status}`; |
| return null; |
| } |
| const expiresInSecRaw = (json as { expires_in?: unknown }).expires_in; |
| const expiresInSec = |
| typeof expiresInSecRaw === 'number' |
| ? expiresInSecRaw |
| : typeof expiresInSecRaw === 'string' |
| ? Number.parseInt(expiresInSecRaw, 10) || 7200 |
| : 7200; |
| this.token = { value: json.access_token, expiresAt: now + expiresInSec * 1_000 }; |
| return this.token.value; |
| } catch (error) { |
| this.reason = error instanceof Error ? error.message : String(error); |
| return null; |
| } |
| } |
| |
| private async startGateway(): Promise<void> { |
| const token = await this.refreshTokenIfNeeded(); |
| if (!token) { |
| this.readiness = 'configured'; |
| this.emitStatusChange(); |
| this.scheduleReconnect(); |
| return; |
| } |
| try { |
| const response = await proxiedFetch(`${QQ_API}/gateway/bot`, { |
| method: 'GET', |
| headers: { Authorization: `QQBot ${token}` }, |
| timeoutMs: 10_000, |
| }); |
| const json = (await response.json().catch(() => null)) as { url?: unknown } | null; |
| if (!response.ok || !json || typeof json.url !== 'string') { |
| this.reason = `gateway-bot-${response.status}`; |
| this.readiness = 'configured'; |
| this.emitStatusChange(); |
| this.scheduleReconnect(); |
| return; |
| } |
| this.connect(json.url); |
| } catch (error) { |
| this.reason = error instanceof Error ? error.message : String(error); |
| this.readiness = 'configured'; |
| this.emitStatusChange(); |
| this.scheduleReconnect(); |
| } |
| } |
| |
| private connect(url: string): void { |
| let ws: WebSocket; |
| try { |
| ws = new WebSocket(url); |
| } catch (error) { |
| this.reason = error instanceof Error ? error.message : String(error); |
| this.readiness = 'configured'; |
| this.emitStatusChange(); |
| this.scheduleReconnect(); |
| return; |
| } |
| this.ws = ws; |
| ws.addEventListener('open', () => { |
| this.running = true; |
| this.startedAt = Date.now(); |
| }); |
| ws.addEventListener('message', (event: { data: unknown }) => { |
| const data = event.data; |
| this.handlePayload(typeof data === 'string' ? data : String(data)); |
| }); |
| ws.addEventListener('close', (event: { code: number; reason: string }) => { |
| this.handleClose(event.code, event.reason); |
| }); |
| ws.addEventListener('error', () => { |
| // The close event fires immediately after; no separate handling. |
| }); |
| } |
| |
| private handlePayload(data: string): void { |
| let payload: { op?: number; s?: number | null; t?: string; d?: unknown }; |
| try { |
| payload = JSON.parse(data); |
| } catch { |
| return; |
| } |
| if (typeof payload.s === 'number') this.seq = payload.s; |
| switch (payload.op) { |
| case OP_HELLO: |
| this.onHello(payload.d as { heartbeat_interval: number }); |
| break; |
| case OP_HEARTBEAT_ACK: |
| this.heartbeatAcked = true; |
| break; |
| case OP_DISPATCH: |
| this.onDispatch(payload.t ?? '', payload.d); |
| break; |
| case OP_HEARTBEAT: |
| this.sendHeartbeat(); |
| break; |
| case OP_RECONNECT: |
| this.forceReconnect(true); |
| break; |
| case OP_INVALID_SESSION: |
| this.forceReconnect(payload.d === true); |
| break; |
| } |
| } |
| |
| private onHello(d: { heartbeat_interval: number }): void { |
| this.heartbeatInterval = d.heartbeat_interval; |
| this.heartbeatAcked = true; |
| this.scheduleHeartbeat(Math.random() * d.heartbeat_interval); |
| if (this.sessionId && this.seq !== null) { |
| this.sendResume(); |
| } else { |
| void this.sendIdentify(); |
| } |
| } |
| |
| private async sendIdentify(): Promise<void> { |
| const token = await this.refreshTokenIfNeeded(); |
| if (!token) return; |
| this.send({ |
| op: OP_IDENTIFY, |
| d: { |
| token: `QQBot ${token}`, |
| intents: QQ_INTENTS, |
| shard: [0, 1], |
| properties: { $os: 'linux', $browser: 'maka', $device: 'maka' }, |
| }, |
| }); |
| } |
| |
| private async sendResume(): Promise<void> { |
| const token = await this.refreshTokenIfNeeded(); |
| if (!token) return; |
| this.send({ |
| op: OP_RESUME, |
| d: { |
| token: `QQBot ${token}`, |
| session_id: this.sessionId, |
| seq: this.seq, |
| }, |
| }); |
| } |
| |
| private sendHeartbeat(): void { |
| if (!this.ws) return; |
| if (!this.heartbeatAcked) { |
| this.forceReconnect(true); |
| return; |
| } |
| this.heartbeatAcked = false; |
| this.send({ op: OP_HEARTBEAT, d: this.seq }); |
| } |
| |
| private scheduleHeartbeat(initialDelay: number): void { |
| this.clearHeartbeat(); |
| const initial = setTimeout(() => { |
| this.sendHeartbeat(); |
| this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatInterval); |
| this.heartbeatTimer.unref?.(); |
| }, initialDelay); |
| initial.unref?.(); |
| } |
| |
| private onDispatch(type: string, d: unknown): void { |
| if (type === 'READY') { |
| const ready = d as QQReadyPayload; |
| this.sessionId = ready.session_id; |
| this.identity = { |
| id: String(ready.user.id), |
| username: ready.user.username, |
| displayName: ready.user.username, |
| }; |
| this.readiness = 'operational'; |
| this.reason = undefined; |
| this.reconnectAttempts = 0; |
| this.emitStatusChange(); |
| return; |
| } |
| if (type === 'RESUMED') { |
| this.readiness = 'operational'; |
| this.reason = undefined; |
| this.reconnectAttempts = 0; |
| this.emitStatusChange(); |
| return; |
| } |
| const receivedAt = Date.now(); |
| let event: ReturnType<typeof qqChannelMessageToEvent> = null; |
| if (type === QQ_EVENT_AT_MESSAGE) { |
| event = qqChannelMessageToEvent(d as QQChannelMessagePayload, receivedAt); |
| } else if (type === QQ_EVENT_DIRECT_MESSAGE) { |
| // DMs use the channel-message shape. |
| const channelLike = qqChannelMessageToEvent(d as QQChannelMessagePayload, receivedAt); |
| if (channelLike) { |
| event = { ...channelLike, isGroup: false, chatId: `dm:${channelLike.chatId}` }; |
| } |
| } else if (type === QQ_EVENT_GROUP_AT_MESSAGE) { |
| event = qqGroupMessageToEvent(d as QQGroupMessagePayload, receivedAt); |
| } else if (type === QQ_EVENT_C2C_MESSAGE) { |
| event = qqC2CMessageToEvent(d as QQC2CMessagePayload, receivedAt); |
| } |
| if (!event) return; |
| this.lastEventAt = event.receivedAt; |
| this.emitIncomingMessage(event); |
| this.emitStatusChange(); |
| } |
| |
| private send(payload: object): void { |
| if (!this.ws || this.ws.readyState !== 1) return; |
| try { |
| this.ws.send(JSON.stringify(payload)); |
| } catch { |
| // Swallow — close handler will fire if the socket died. |
| } |
| } |
| |
| private clearHeartbeat(): void { |
| if (this.heartbeatTimer) { |
| clearInterval(this.heartbeatTimer); |
| this.heartbeatTimer = null; |
| } |
| } |
| |
| private clearReconnect(): void { |
| if (this.reconnectTimer) { |
| clearTimeout(this.reconnectTimer); |
| this.reconnectTimer = null; |
| } |
| } |
| |
| private handleClose(code: number, reason: string): void { |
| this.clearHeartbeat(); |
| this.ws = null; |
| this.running = false; |
| const decision = decideQQClose(code, this.explicitlyStopped); |
| if (decision.kind === 'stopped') return; |
| if (decision.kind === 'fatal') { |
| this.readiness = 'configured'; |
| this.reason = `gateway-closed-${code}`; |
| this.sessionId = null; |
| this.seq = null; |
| this.emitStatusChange(); |
| return; |
| } |
| if (!decision.resumable) { |
| this.sessionId = null; |
| this.seq = null; |
| } |
| this.readiness = 'degraded'; |
| this.reason = reason || `gateway-closed-${code}`; |
| this.emitStatusChange(); |
| this.scheduleReconnect(); |
| } |
| |
| private forceReconnect(resumable: boolean): void { |
| if (!resumable) { |
| this.sessionId = null; |
| this.seq = null; |
| } |
| if (this.ws) { |
| try { |
| this.ws.close(); |
| } catch { |
| /* swallow */ |
| } |
| this.ws = null; |
| } |
| this.clearHeartbeat(); |
| this.scheduleReconnect(); |
| } |
| |
| private scheduleReconnect(): void { |
| if (this.explicitlyStopped) return; |
| this.clearReconnect(); |
| const delay = qqReconnectBackoffMs(this.reconnectAttempts); |
| this.reconnectAttempts += 1; |
| this.reconnectTimer = setTimeout(() => { |
| this.reconnectTimer = null; |
| void this.startGateway(); |
| }, delay); |
| this.reconnectTimer.unref?.(); |
| } |
| } |
| |
| export const __TEST__ = { |
| decideQQClose, |
| qqReconnectBackoffMs, |
| classifyQQSendResponse, |
| qqChannelMessageToEvent, |
| qqGroupMessageToEvent, |
| qqC2CMessageToEvent, |
| pickQQSendRoute, |
| pickQQTypingRoute, |
| QQ_INTENTS, |
| }; |