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.
- package/LICENSE +21 -0
- package/README.en.md +276 -0
- package/README.md +211 -0
- package/client-src/plugin.cts +15 -0
- package/client-src/settings.cts +216 -0
- package/client-src/shared.cts +104 -0
- package/client-src/workspace.cts +164 -0
- package/cordis.patch.yml +7 -0
- package/docs/ARCHITECTURE.en.md +346 -0
- package/docs/ARCHITECTURE.md +411 -0
- package/docs/DEVELOPMENT.md +69 -0
- package/docs/MEDIA_AND_QUOTES.md +644 -0
- package/docs/MEMORY.md +99 -0
- package/docs/MEMORY_CONTEXT.md +254 -0
- package/docs/QUOTES.md +355 -0
- package/docs/README.md +22 -0
- package/docs/SECURITY.md +62 -0
- package/docs/STREAMING.md +347 -0
- package/docs/assets//346/211/253/347/240/201/351/223/276/346/216/245.jpg +0 -0
- package/docs/assets//347/276/244/350/201/212/347/225/214/351/235/242.jpg +0 -0
- package/docs/assets//350/256/260/345/277/206/347/263/273/347/273/237.jpg +0 -0
- package/docs/assets//350/256/276/347/275/256/347/225/214/351/235/242.jpg +0 -0
- package/docs/devlog.md +24 -0
- package/lib/client.js +433 -0
- package/lib/commands/dispatch.d.ts +16 -0
- package/lib/commands/dispatch.js +48 -0
- package/lib/config.d.ts +3 -0
- package/lib/config.js +63 -0
- package/lib/gateway/api.d.ts +30 -0
- package/lib/gateway/api.js +202 -0
- package/lib/gateway/auth.d.ts +35 -0
- package/lib/gateway/auth.js +80 -0
- package/lib/gateway/crypto.d.ts +2 -0
- package/lib/gateway/crypto.js +17 -0
- package/lib/gateway/gateway.d.ts +32 -0
- package/lib/gateway/gateway.js +219 -0
- package/lib/gateway/normalize.d.ts +6 -0
- package/lib/gateway/normalize.js +248 -0
- package/lib/index.d.ts +14 -0
- package/lib/index.js +35 -0
- package/lib/media/image-tool.d.ts +4 -0
- package/lib/media/image-tool.js +62 -0
- package/lib/media/media-tools.d.ts +3 -0
- package/lib/media/media-tools.js +68 -0
- package/lib/media/store.d.ts +14 -0
- package/lib/media/store.js +176 -0
- package/lib/session/agent-bridge.d.ts +56 -0
- package/lib/session/agent-bridge.js +613 -0
- package/lib/session/memory-snapshot.d.ts +23 -0
- package/lib/session/memory-snapshot.js +27 -0
- package/lib/session/runtime.d.ts +41 -0
- package/lib/session/runtime.js +395 -0
- package/lib/shared/augmentations.d.ts +40 -0
- package/lib/shared/augmentations.js +1 -0
- package/lib/shared/logging.d.ts +14 -0
- package/lib/shared/logging.js +41 -0
- package/lib/storage/db.d.ts +82 -0
- package/lib/storage/db.js +669 -0
- package/lib/storage/memory.d.ts +50 -0
- package/lib/storage/memory.js +566 -0
- package/lib/transport/rpc.d.ts +18 -0
- package/lib/transport/rpc.js +318 -0
- package/lib/types.d.ts +416 -0
- package/lib/types.js +1 -0
- package/package.json +115 -0
- package/scripts/wrap-client.mjs +20 -0
- package/src/commands/dispatch.ts +75 -0
- package/src/config.ts +70 -0
- package/src/gateway/api.ts +260 -0
- package/src/gateway/auth.ts +99 -0
- package/src/gateway/crypto.ts +16 -0
- package/src/gateway/gateway.ts +207 -0
- package/src/gateway/normalize.ts +236 -0
- package/src/index.ts +46 -0
- package/src/media/image-tool.ts +62 -0
- package/src/media/media-tools.ts +66 -0
- package/src/media/store.ts +157 -0
- package/src/session/agent-bridge.ts +624 -0
- package/src/session/memory-snapshot.ts +41 -0
- package/src/session/runtime.ts +368 -0
- package/src/shared/augmentations.ts +45 -0
- package/src/shared/logging.ts +36 -0
- package/src/storage/db.ts +760 -0
- package/src/storage/memory.ts +581 -0
- package/src/transport/rpc.ts +316 -0
- package/src/types.ts +456 -0
- package/tsconfig.build.json +12 -0
- package/tsconfig.client.json +14 -0
- 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
|
+
}
|