dsh-qqchat 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +276 -0
  3. package/README.md +211 -0
  4. package/client-src/plugin.cts +15 -0
  5. package/client-src/settings.cts +216 -0
  6. package/client-src/shared.cts +104 -0
  7. package/client-src/workspace.cts +164 -0
  8. package/cordis.patch.yml +7 -0
  9. package/docs/ARCHITECTURE.en.md +346 -0
  10. package/docs/ARCHITECTURE.md +411 -0
  11. package/docs/DEVELOPMENT.md +69 -0
  12. package/docs/MEDIA_AND_QUOTES.md +644 -0
  13. package/docs/MEMORY.md +99 -0
  14. package/docs/MEMORY_CONTEXT.md +254 -0
  15. package/docs/QUOTES.md +355 -0
  16. package/docs/README.md +22 -0
  17. package/docs/SECURITY.md +62 -0
  18. package/docs/STREAMING.md +347 -0
  19. package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
  20. package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
  21. package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
  22. package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
  23. package/docs/devlog.md +24 -0
  24. package/lib/client.js +433 -0
  25. package/lib/commands/dispatch.d.ts +16 -0
  26. package/lib/commands/dispatch.js +48 -0
  27. package/lib/config.d.ts +3 -0
  28. package/lib/config.js +63 -0
  29. package/lib/gateway/api.d.ts +30 -0
  30. package/lib/gateway/api.js +202 -0
  31. package/lib/gateway/auth.d.ts +35 -0
  32. package/lib/gateway/auth.js +80 -0
  33. package/lib/gateway/crypto.d.ts +2 -0
  34. package/lib/gateway/crypto.js +17 -0
  35. package/lib/gateway/gateway.d.ts +32 -0
  36. package/lib/gateway/gateway.js +219 -0
  37. package/lib/gateway/normalize.d.ts +6 -0
  38. package/lib/gateway/normalize.js +248 -0
  39. package/lib/index.d.ts +14 -0
  40. package/lib/index.js +35 -0
  41. package/lib/media/image-tool.d.ts +4 -0
  42. package/lib/media/image-tool.js +62 -0
  43. package/lib/media/media-tools.d.ts +3 -0
  44. package/lib/media/media-tools.js +68 -0
  45. package/lib/media/store.d.ts +14 -0
  46. package/lib/media/store.js +176 -0
  47. package/lib/session/agent-bridge.d.ts +56 -0
  48. package/lib/session/agent-bridge.js +613 -0
  49. package/lib/session/memory-snapshot.d.ts +23 -0
  50. package/lib/session/memory-snapshot.js +27 -0
  51. package/lib/session/runtime.d.ts +41 -0
  52. package/lib/session/runtime.js +395 -0
  53. package/lib/shared/augmentations.d.ts +40 -0
  54. package/lib/shared/augmentations.js +1 -0
  55. package/lib/shared/logging.d.ts +14 -0
  56. package/lib/shared/logging.js +41 -0
  57. package/lib/storage/db.d.ts +82 -0
  58. package/lib/storage/db.js +669 -0
  59. package/lib/storage/memory.d.ts +50 -0
  60. package/lib/storage/memory.js +566 -0
  61. package/lib/transport/rpc.d.ts +18 -0
  62. package/lib/transport/rpc.js +318 -0
  63. package/lib/types.d.ts +416 -0
  64. package/lib/types.js +1 -0
  65. package/package.json +115 -0
  66. package/scripts/wrap-client.mjs +20 -0
  67. package/src/commands/dispatch.ts +75 -0
  68. package/src/config.ts +70 -0
  69. package/src/gateway/api.ts +260 -0
  70. package/src/gateway/auth.ts +99 -0
  71. package/src/gateway/crypto.ts +16 -0
  72. package/src/gateway/gateway.ts +207 -0
  73. package/src/gateway/normalize.ts +236 -0
  74. package/src/index.ts +46 -0
  75. package/src/media/image-tool.ts +62 -0
  76. package/src/media/media-tools.ts +66 -0
  77. package/src/media/store.ts +157 -0
  78. package/src/session/agent-bridge.ts +624 -0
  79. package/src/session/memory-snapshot.ts +41 -0
  80. package/src/session/runtime.ts +368 -0
  81. package/src/shared/augmentations.ts +45 -0
  82. package/src/shared/logging.ts +36 -0
  83. package/src/storage/db.ts +760 -0
  84. package/src/storage/memory.ts +581 -0
  85. package/src/transport/rpc.ts +316 -0
  86. package/src/types.ts +456 -0
  87. package/tsconfig.build.json +12 -0
  88. package/tsconfig.client.json +14 -0
  89. package/tsconfig.json +23 -0
@@ -0,0 +1,260 @@
1
+ import type { QQChatDatabase } from '../storage/db.js'
2
+ import type {
3
+ AccountRow,
4
+ QQAccessTokenPayload,
5
+ QQChatConfig,
6
+ QQGatewayPayload,
7
+ QQSendOptions,
8
+ ReplyFormat,
9
+ } from '../types.js'
10
+
11
+ const API_BASE = 'https://api.sgroup.qq.com'
12
+ const SANDBOX_API_BASE = 'https://sandbox.api.sgroup.qq.com'
13
+ const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken'
14
+
15
+ function looksLikeMarkdown(text: string): boolean {
16
+ return /(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s)|\*\*|__|`|\[[^\]]+\]\([^\)]+\)/m.test(text)
17
+ }
18
+
19
+ interface CachedToken {
20
+ token: string
21
+ expiresAt: number
22
+ }
23
+
24
+ interface QQSendBody {
25
+ msg_type: number
26
+ msg_seq: number
27
+ msg_id?: string
28
+ content?: string
29
+ markdown?: { content: string }
30
+ }
31
+
32
+ interface QQStreamBody {
33
+ input_mode: 'replace'
34
+ input_state: 1 | 10
35
+ index: number
36
+ content_type: 'text' | 'markdown'
37
+ content_raw: string
38
+ msg_seq: number
39
+ event_id?: string
40
+ msg_id?: string
41
+ stream_msg_id?: string
42
+ }
43
+
44
+ export interface QQPrivateTextStream {
45
+ push(delta: string): void
46
+ finish(text: string): Promise<void>
47
+ hasSent(): boolean
48
+ }
49
+
50
+ export class QQApiClient {
51
+ private readonly tokens = new Map<number, CachedToken>()
52
+ private readonly sequences = new Map<string, number>()
53
+ private streamSequence = (Date.now() ^ Math.floor(Math.random() * 65_536)) & 0xffff
54
+
55
+ constructor(
56
+ private readonly db: QQChatDatabase,
57
+ readonly config: QQChatConfig,
58
+ ) {}
59
+
60
+ clearToken(accountId: number): void { this.tokens.delete(Number(accountId)) }
61
+
62
+ async token(account: AccountRow): Promise<string> {
63
+ const id = Number(account.id)
64
+ const cached = this.tokens.get(id)
65
+ if (cached && cached.expiresAt > Date.now() + 60_000) return cached.token
66
+ const response = await fetch(TOKEN_URL, {
67
+ method: 'POST', headers: { 'content-type': 'application/json' },
68
+ body: JSON.stringify({ appId: account.app_id, clientSecret: account.app_secret }),
69
+ signal: AbortSignal.timeout(15_000),
70
+ })
71
+ const data = await safeJson<QQAccessTokenPayload>(response)
72
+ if (!response.ok || !data?.access_token) throw new Error(`QQ access_token 获取失败: HTTP ${response.status}`)
73
+ const ttl = Number(data.expires_in || 7200)
74
+ this.tokens.set(id, { token: data.access_token, expiresAt: Date.now() + Math.max(60, ttl) * 1000 })
75
+ return data.access_token
76
+ }
77
+
78
+ async gatewayUrl(account: AccountRow): Promise<string> {
79
+ const token = await this.token(account)
80
+ const base = account.sandbox ? SANDBOX_API_BASE : API_BASE
81
+ const response = await fetch(`${base}/gateway`, {
82
+ headers: { Authorization: `QQBot ${token}` }, signal: AbortSignal.timeout(15_000),
83
+ })
84
+ const data = await safeJson<QQGatewayPayload>(response)
85
+ if (!response.ok || !data?.url) throw new Error(`QQ gateway 获取失败: HTTP ${response.status}`)
86
+ return data.url
87
+ }
88
+
89
+ private nextSeq(messageId: string | null | undefined): number {
90
+ const key = messageId || `active:${Date.now()}:${Math.random()}`
91
+ const next = (this.sequences.get(key) || 0) + 1
92
+ this.sequences.set(key, next)
93
+ return next
94
+ }
95
+
96
+ /**
97
+ * QQ stream_messages requires a fresh msg_seq for each stream session.
98
+ * It is intentionally not scoped by the inbound message id: using 1 for
99
+ * every reply causes QQ to reject later streams as duplicated messages.
100
+ */
101
+ private nextStreamSeq(): number {
102
+ this.streamSequence = (this.streamSequence + 1) & 0xffff
103
+ return this.streamSequence
104
+ }
105
+
106
+ private async request<T = Record<string, unknown>>(
107
+ account: AccountRow,
108
+ method: string,
109
+ path: string,
110
+ body: unknown,
111
+ retry = true,
112
+ ): Promise<T> {
113
+ const token = await this.token(account)
114
+ const base = account.sandbox ? SANDBOX_API_BASE : API_BASE
115
+ const response = await fetch(`${base}${path}`, {
116
+ method,
117
+ headers: { Authorization: `QQBot ${token}`, 'content-type': 'application/json' },
118
+ body: body === undefined ? undefined : JSON.stringify(body),
119
+ signal: AbortSignal.timeout(20_000),
120
+ })
121
+ const data = await safeJson<Record<string, unknown>>(response)
122
+ if (response.status === 401 && retry) {
123
+ this.clearToken(account.id)
124
+ return this.request<T>(account, method, path, body, false)
125
+ }
126
+ if (!response.ok) {
127
+ const code = data?.code ? ` code=${String(data.code)}` : ''
128
+ const message = data?.message ? ` ${String(data.message)}` : ''
129
+ throw new Error(`QQ API HTTP ${response.status}${code}${message}`)
130
+ }
131
+ return (data || {}) as T
132
+ }
133
+
134
+ async sendText(
135
+ account: AccountRow,
136
+ targetId: string,
137
+ text: string,
138
+ { group = false, messageId = null, format = this.config.replyFormat }: QQSendOptions = {},
139
+ ): Promise<Record<string, unknown>> {
140
+ const path = group ? `/v2/groups/${encodeURIComponent(targetId)}/messages` : `/v2/users/${encodeURIComponent(targetId)}/messages`
141
+ const seq = this.nextSeq(messageId)
142
+ const useMarkdown = format === 'markdown' || (format === 'smart' && looksLikeMarkdown(text))
143
+ const makeBody = (markdown: boolean): QQSendBody => ({
144
+ msg_type: markdown ? 2 : 0,
145
+ ...(markdown ? { markdown: { content: text } } : { content: text }),
146
+ msg_seq: seq,
147
+ ...(messageId ? { msg_id: messageId } : {}),
148
+ })
149
+ try {
150
+ return await this.request(account, 'POST', path, makeBody(useMarkdown))
151
+ } catch (error) {
152
+ if (useMarkdown && /50056|markdown|md perm/i.test(String(error))) {
153
+ return this.request(account, 'POST', path, makeBody(false))
154
+ }
155
+ throw error
156
+ }
157
+ }
158
+
159
+ async sendReplyWithActiveFallback(
160
+ account: AccountRow,
161
+ targetId: string,
162
+ text: string,
163
+ options: QQSendOptions,
164
+ ): Promise<Record<string, unknown>> {
165
+ try {
166
+ return await this.sendText(account, targetId, text, options)
167
+ } catch (error) {
168
+ if (!options.messageId) throw error
169
+ return this.sendText(account, targetId, text, { ...options, messageId: null })
170
+ }
171
+ }
172
+
173
+ createPrivateTextStream(
174
+ account: AccountRow,
175
+ targetId: string,
176
+ { messageId = null, format = this.config.replyFormat }: QQSendOptions = {},
177
+ ): QQPrivateTextStream {
178
+ const path = `/v2/users/${encodeURIComponent(targetId)}/stream_messages`
179
+ const msgSeq = this.nextStreamSeq()
180
+ const contentType = format === 'markdown' ? 'markdown' : 'text'
181
+ let fullText = ''
182
+ let streamMessageId: string | undefined
183
+ let index = 0
184
+ let sent = false
185
+ let timer: ReturnType<typeof setTimeout> | undefined
186
+ let chain = Promise.resolve()
187
+ let finished = false
188
+ let lastSentText = ''
189
+
190
+ const enqueue = (inputState: 1 | 10, content: string): Promise<void> => {
191
+ chain = chain.then(async () => {
192
+ // Build the request only when the queued task runs. The first
193
+ // response supplies stream_msg_id for all following frames.
194
+ const body: QQStreamBody = {
195
+ input_mode: 'replace', input_state: inputState, index: index++, content_type: contentType,
196
+ content_raw: content, msg_seq: msgSeq,
197
+ ...(messageId ? { msg_id: messageId, event_id: messageId } : {}),
198
+ ...(streamMessageId ? { stream_msg_id: streamMessageId } : {}),
199
+ }
200
+ let attempt = 0
201
+ while (true) {
202
+ try {
203
+ const result = await this.request<{ stream_msg_id?: unknown; id?: unknown }>(account, 'POST', path, body)
204
+ sent = true
205
+ if (!streamMessageId) {
206
+ const id = typeof result.stream_msg_id === 'string' ? result.stream_msg_id : result.id
207
+ if (typeof id === 'string' && id) streamMessageId = id
208
+ }
209
+ lastSentText = content
210
+ return
211
+ } catch (error) {
212
+ const message = String(error)
213
+ const rateLimited = /HTTP 429|50002|rate limit/i.test(message)
214
+ if (!rateLimited || attempt >= 3) throw error
215
+ const delay = 1000 * (2 ** attempt++)
216
+ await new Promise(resolve => setTimeout(resolve, delay))
217
+ }
218
+ }
219
+ })
220
+ return chain
221
+ }
222
+
223
+ const flush = (inputState: 1 | 10 = 1): Promise<void> => {
224
+ timer = undefined
225
+ if (!fullText && inputState !== 10) return chain
226
+ if (inputState !== 10 && fullText === lastSentText) return chain
227
+ return enqueue(inputState, fullText)
228
+ }
229
+
230
+ return {
231
+ push: delta => {
232
+ if (!delta || finished) return
233
+ fullText += delta
234
+ if (!timer) {
235
+ timer = setTimeout(() => { void flush().catch(() => {}) }, 500)
236
+ timer.unref?.()
237
+ }
238
+ },
239
+ finish: async text => {
240
+ finished = true
241
+ if (timer) clearTimeout(timer)
242
+ timer = undefined
243
+ fullText = text
244
+ await flush(1)
245
+ await chain
246
+ await flush(10)
247
+ await chain
248
+ },
249
+ hasSent: () => sent,
250
+ }
251
+ }
252
+ }
253
+
254
+ async function safeJson<T>(response: Response): Promise<T | undefined> {
255
+ try { return await response.json() as T } catch { return undefined }
256
+ }
257
+
258
+ export function isReplyFormat(value: string): value is ReplyFormat {
259
+ return value === 'smart' || value === 'markdown' || value === 'compat'
260
+ }
@@ -0,0 +1,99 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import QRCode from 'qrcode'
3
+ import { decryptQQSecret } from './crypto.js'
4
+ import type { QQChatDatabase } from '../storage/db.js'
5
+ import type { AccountRow, QQBindCreatePayload, QQBindPollPayload, QQChatConfig } from '../types.js'
6
+
7
+ const CREATE_URL = 'https://q.qq.com/lite/create_bind_task'
8
+ const POLL_URL = 'https://q.qq.com/lite/poll_bind_result'
9
+ const FRONTEND = 'https://q.qq.com/qqbot/openclaw/connect.html'
10
+ const TASK_TTL_MS = 600_000
11
+
12
+ export interface QQBindStartResult {
13
+ taskId: string
14
+ scanUrl: string
15
+ qrDataUrl: string
16
+ expiresInMs: number
17
+ }
18
+
19
+ export type QQBindPollResult =
20
+ | { status: 'waiting' }
21
+ | { status: 'expired' }
22
+ | { status: 'fail'; reason: string }
23
+ | { status: 'success'; account: PublicAccount }
24
+
25
+ export interface PublicAccount {
26
+ id: number
27
+ appId: string
28
+ botUserId: string | null
29
+ enabled: boolean
30
+ sandbox: boolean
31
+ gatewayStatus: string
32
+ gatewayLastError: string | null
33
+ }
34
+
35
+ export class QQBindService {
36
+ constructor(
37
+ private readonly db: QQChatDatabase,
38
+ private readonly config: QQChatConfig,
39
+ ) {}
40
+
41
+ async start(): Promise<QQBindStartResult> {
42
+ const aesKey = randomBytes(32).toString('base64')
43
+ const response = await fetch(CREATE_URL, {
44
+ method: 'POST',
45
+ headers: { 'content-type': 'application/json' },
46
+ body: JSON.stringify({ key: aesKey }),
47
+ signal: AbortSignal.timeout(15_000),
48
+ })
49
+ if (!response.ok) throw new Error(`QQ 创建连接任务失败: HTTP ${response.status}`)
50
+ const payload = await response.json() as QQBindCreatePayload
51
+ if (payload.retcode !== 0) throw new Error(`QQ 创建连接任务失败: ${payload.msg || 'unknown'}`)
52
+ const taskId = payload.data?.task_id
53
+ if (!taskId) throw new Error('QQ 未返回 task_id')
54
+ this.db.saveAuthTask(String(taskId), aesKey, TASK_TTL_MS)
55
+ const scanUrl = `${FRONTEND}?task_id=${encodeURIComponent(taskId)}&_wv=2&source=${encodeURIComponent(this.config.source)}`
56
+ const qrDataUrl = await QRCode.toDataURL(scanUrl, { width: 224, margin: 1, errorCorrectionLevel: 'M' })
57
+ return { taskId: String(taskId), scanUrl, qrDataUrl, expiresInMs: TASK_TTL_MS }
58
+ }
59
+
60
+ async poll(taskId: string): Promise<QQBindPollResult> {
61
+ const task = this.db.getAuthTask(taskId)
62
+ if (!task) return { status: 'expired' }
63
+ const response = await fetch(POLL_URL, {
64
+ method: 'POST',
65
+ headers: { 'content-type': 'application/json' },
66
+ body: JSON.stringify({ task_id: taskId }),
67
+ signal: AbortSignal.timeout(10_000),
68
+ })
69
+ if (!response.ok) throw new Error(`QQ 轮询授权失败: HTTP ${response.status}`)
70
+ const payload = await response.json() as QQBindPollPayload
71
+ if (payload.retcode !== 0) return { status: 'fail', reason: payload.msg || 'unknown' }
72
+ const data = payload.data || {}
73
+ if (data.status === 3) {
74
+ this.db.deleteAuthTask(taskId)
75
+ return { status: 'expired' }
76
+ }
77
+ if (data.status !== 2) return { status: 'waiting' }
78
+ const appId = String(data.bot_appid || '')
79
+ const encrypted = String(data.bot_encrypt_secret || '')
80
+ if (!appId || !encrypted) return { status: 'fail', reason: 'QQ 返回缺少 AppID 或 Secret' }
81
+ let secret: string
82
+ try {
83
+ secret = decryptQQSecret(encrypted, task.aes_key)
84
+ } catch {
85
+ return { status: 'fail', reason: 'QQ Secret 解密失败' }
86
+ }
87
+ const account = this.db.upsertAccount(appId, secret, this.config.sandbox)
88
+ this.db.deleteAuthTask(taskId)
89
+ return { status: 'success', account: publicAccount(account) }
90
+ }
91
+ }
92
+
93
+ function publicAccount(account: AccountRow): PublicAccount {
94
+ return {
95
+ id: Number(account.id), appId: account.app_id, botUserId: account.bot_user_id || null,
96
+ enabled: account.enabled === 1, sandbox: account.sandbox === 1,
97
+ gatewayStatus: account.gateway_status, gatewayLastError: account.gateway_last_error || null,
98
+ }
99
+ }
@@ -0,0 +1,16 @@
1
+ import { createDecipheriv } from 'node:crypto'
2
+
3
+ /** QQ bind_task returns base64(iv[12] + ciphertext + gcmTag[16]). */
4
+ export function decryptQQSecret(encryptedBase64: string, keyBase64: string): string {
5
+ const key = Buffer.from(keyBase64, 'base64')
6
+ const raw = Buffer.from(encryptedBase64, 'base64')
7
+ if (key.length !== 32) throw new Error('QQ bind AES key must be 32 bytes')
8
+ if (raw.length < 28) throw new Error('QQ bind ciphertext is too short')
9
+ const iv = raw.subarray(0, 12)
10
+ const body = raw.subarray(12)
11
+ const tag = body.subarray(body.length - 16)
12
+ const ciphertext = body.subarray(0, body.length - 16)
13
+ const decipher = createDecipheriv('aes-256-gcm', key, iv)
14
+ decipher.setAuthTag(tag)
15
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
16
+ }
@@ -0,0 +1,207 @@
1
+ import { normalizeQQDispatch } from './normalize.js'
2
+ import type { QQApiClient } from './api.js'
3
+ import type { QQChatDatabase } from '../storage/db.js'
4
+ import type { AccountRow, LoggerLike, QQDispatchData, QQGatewayFrame, QQNormalizedMessage } from '../types.js'
5
+
6
+ const OP = {
7
+ DISPATCH: 0,
8
+ HEARTBEAT: 1,
9
+ IDENTIFY: 2,
10
+ RESUME: 6,
11
+ RECONNECT: 7,
12
+ INVALID_SESSION: 9,
13
+ HELLO: 10,
14
+ HEARTBEAT_ACK: 11,
15
+ } as const
16
+
17
+ const INTENTS = 1 << 25
18
+ const RECONNECT_DELAYS = [1000, 2000, 5000, 10_000, 30_000, 60_000] as const
19
+
20
+ type QQMessageHandler = (message: QQNormalizedMessage) => Promise<void> | void
21
+
22
+ export class QQGateway {
23
+ private stopped = false
24
+ private ws?: WebSocket
25
+ private sessionId?: string
26
+ private lastSeq: number | null = null
27
+ private heartbeatTimer?: ReturnType<typeof setInterval>
28
+ private ackTimer?: ReturnType<typeof setTimeout>
29
+ private lastAckAt = 0
30
+ private loopPromise?: Promise<void>
31
+ private readonly dispatchChains = new Map<string, Promise<void>>()
32
+
33
+ constructor(
34
+ readonly account: AccountRow,
35
+ private readonly db: QQChatDatabase,
36
+ private readonly api: QQApiClient,
37
+ private readonly onMessage: QQMessageHandler,
38
+ private readonly logger: LoggerLike = console,
39
+ ) {}
40
+
41
+ start(): void {
42
+ if (this.loopPromise) return
43
+ this.stopped = false
44
+ this.loopPromise = this.loop().finally(() => { this.loopPromise = undefined })
45
+ }
46
+
47
+ async stop(): Promise<void> {
48
+ this.stopped = true
49
+ this.clearHeartbeat()
50
+ try { this.ws?.close() } catch {}
51
+ try { await this.loopPromise } catch {}
52
+ this.db.setAccountGateway(this.account.id, 'offline', null)
53
+ }
54
+
55
+ private async loop(): Promise<void> {
56
+ let attempt = 0
57
+ while (!this.stopped) {
58
+ try {
59
+ this.db.setAccountGateway(this.account.id, 'connecting', null)
60
+ await this.connectOnce()
61
+ attempt = 0
62
+ } catch (error) {
63
+ if (this.stopped) break
64
+ const message = error instanceof Error ? error.message : String(error)
65
+ this.db.setAccountGateway(this.account.id, 'error', message.slice(0, 500))
66
+ this.logger.warn?.(`[dsh-qqchat] QQ gateway: ${message}`)
67
+ const delay = RECONNECT_DELAYS[Math.min(attempt++, RECONNECT_DELAYS.length - 1)] ?? RECONNECT_DELAYS.at(-1) ?? 60_000
68
+ await sleep(delay)
69
+ }
70
+ }
71
+ }
72
+
73
+ private async connectOnce(): Promise<void> {
74
+ const [url, token] = await Promise.all([this.api.gatewayUrl(this.account), this.api.token(this.account)])
75
+ await new Promise<void>((resolve, reject) => {
76
+ const ws = new WebSocket(url)
77
+ this.ws = ws
78
+ let settled = false
79
+ const finish = (error?: Error): void => {
80
+ if (settled) return
81
+ settled = true
82
+ this.clearHeartbeat()
83
+ this.ws = undefined
84
+ if (error) reject(error)
85
+ else resolve()
86
+ }
87
+ ws.addEventListener('error', () => finish(new Error('QQ WebSocket 连接错误')))
88
+ ws.addEventListener('close', event => {
89
+ if (this.stopped) finish()
90
+ else finish(new Error(`QQ WebSocket 已断开 (${event.code})`))
91
+ })
92
+ ws.addEventListener('message', event => {
93
+ this.handleFrame(ws, token, event.data).catch(error => {
94
+ try { ws.close() } catch {}
95
+ finish(error instanceof Error ? error : new Error(String(error)))
96
+ })
97
+ })
98
+ })
99
+ }
100
+
101
+ private async handleFrame(ws: WebSocket, token: string, raw: unknown): Promise<void> {
102
+ const text = await asText(raw)
103
+ const frame = JSON.parse(text) as QQGatewayFrame
104
+ if (typeof frame.s === 'number') this.lastSeq = frame.s
105
+ switch (frame.op) {
106
+ case OP.HELLO: {
107
+ const interval = Number(frame.d?.heartbeat_interval || 45_000)
108
+ this.installHeartbeat(ws, interval)
109
+ if (this.sessionId) {
110
+ this.send(ws, OP.RESUME, { token: `QQBot ${token}`, session_id: this.sessionId, seq: this.lastSeq })
111
+ } else {
112
+ this.send(ws, OP.IDENTIFY, {
113
+ token: `QQBot ${token}`,
114
+ intents: INTENTS,
115
+ shard: [0, 1],
116
+ properties: { $os: process.platform, $browser: 'dsh-qqchat', $device: 'dsh-qqchat' },
117
+ })
118
+ }
119
+ break
120
+ }
121
+ case OP.HEARTBEAT_ACK:
122
+ this.lastAckAt = Date.now()
123
+ break
124
+ case OP.RECONNECT:
125
+ ws.close(4000, 'server reconnect')
126
+ break
127
+ case OP.INVALID_SESSION:
128
+ this.sessionId = undefined
129
+ this.lastSeq = null
130
+ ws.close(4001, 'invalid session')
131
+ break
132
+ case OP.DISPATCH:
133
+ await this.handleDispatch(frame.t || '', frame.d || {})
134
+ break
135
+ }
136
+ }
137
+
138
+ private async handleDispatch(type: string, data: Record<string, unknown>): Promise<void> {
139
+ if (type === 'READY') {
140
+ const sessionId = data.session_id
141
+ this.sessionId = typeof sessionId === 'string' && sessionId ? sessionId : this.sessionId
142
+ const user = isRecord(data.user) ? data.user : undefined
143
+ const botUserId = String(user?.id || user?.openid || '') || undefined
144
+ this.db.setAccountGateway(this.account.id, 'online', null, botUserId)
145
+ return
146
+ }
147
+ if (type === 'RESUMED') {
148
+ this.db.setAccountGateway(this.account.id, 'online', null)
149
+ return
150
+ }
151
+ const message = normalizeQQDispatch(type, data as QQDispatchData, Number(this.account.id))
152
+ if (message) await this.enqueueMessage(message)
153
+ }
154
+
155
+ private enqueueMessage(message: QQNormalizedMessage): Promise<void> {
156
+ const key = `${this.account.id}:${message.chatType}:${message.chatId}`
157
+ const previous = this.dispatchChains.get(key) || Promise.resolve()
158
+ const current = previous.catch(() => undefined).then(() => this.onMessage(message))
159
+ this.dispatchChains.set(key, current)
160
+ return current.finally(() => {
161
+ if (this.dispatchChains.get(key) === current) this.dispatchChains.delete(key)
162
+ })
163
+ }
164
+
165
+ private installHeartbeat(ws: WebSocket, interval: number): void {
166
+ this.clearHeartbeat()
167
+ this.lastAckAt = Date.now()
168
+ const tick = (): void => {
169
+ if (ws.readyState !== WebSocket.OPEN) return
170
+ if (Date.now() - this.lastAckAt > interval * 2.5) {
171
+ ws.close(4002, 'heartbeat timeout')
172
+ return
173
+ }
174
+ this.send(ws, OP.HEARTBEAT, this.lastSeq)
175
+ }
176
+ this.heartbeatTimer = setInterval(tick, interval)
177
+ const firstTick = setTimeout(tick, Math.min(1000, interval))
178
+ firstTick.unref?.()
179
+ }
180
+
181
+ private clearHeartbeat(): void {
182
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
183
+ if (this.ackTimer) clearTimeout(this.ackTimer)
184
+ this.heartbeatTimer = undefined
185
+ this.ackTimer = undefined
186
+ }
187
+
188
+ private send(ws: WebSocket, op: number, data: unknown): void {
189
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op, d: data }))
190
+ }
191
+ }
192
+
193
+ async function asText(data: unknown): Promise<string> {
194
+ if (typeof data === 'string') return data
195
+ if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8')
196
+ if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8')
197
+ if (data instanceof Blob) return data.text()
198
+ return String(data)
199
+ }
200
+
201
+ function isRecord(value: unknown): value is Record<string, unknown> {
202
+ return typeof value === 'object' && value !== null
203
+ }
204
+
205
+ function sleep(ms: number): Promise<void> {
206
+ return new Promise(resolve => setTimeout(resolve, ms))
207
+ }