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,202 @@
|
|
|
1
|
+
const API_BASE = 'https://api.sgroup.qq.com';
|
|
2
|
+
const SANDBOX_API_BASE = 'https://sandbox.api.sgroup.qq.com';
|
|
3
|
+
const TOKEN_URL = 'https://bots.qq.com/app/getAppAccessToken';
|
|
4
|
+
function looksLikeMarkdown(text) {
|
|
5
|
+
return /(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s)|\*\*|__|`|\[[^\]]+\]\([^\)]+\)/m.test(text);
|
|
6
|
+
}
|
|
7
|
+
export class QQApiClient {
|
|
8
|
+
db;
|
|
9
|
+
config;
|
|
10
|
+
tokens = new Map();
|
|
11
|
+
sequences = new Map();
|
|
12
|
+
streamSequence = (Date.now() ^ Math.floor(Math.random() * 65_536)) & 0xffff;
|
|
13
|
+
constructor(db, config) {
|
|
14
|
+
this.db = db;
|
|
15
|
+
this.config = config;
|
|
16
|
+
}
|
|
17
|
+
clearToken(accountId) { this.tokens.delete(Number(accountId)); }
|
|
18
|
+
async token(account) {
|
|
19
|
+
const id = Number(account.id);
|
|
20
|
+
const cached = this.tokens.get(id);
|
|
21
|
+
if (cached && cached.expiresAt > Date.now() + 60_000)
|
|
22
|
+
return cached.token;
|
|
23
|
+
const response = await fetch(TOKEN_URL, {
|
|
24
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ appId: account.app_id, clientSecret: account.app_secret }),
|
|
26
|
+
signal: AbortSignal.timeout(15_000),
|
|
27
|
+
});
|
|
28
|
+
const data = await safeJson(response);
|
|
29
|
+
if (!response.ok || !data?.access_token)
|
|
30
|
+
throw new Error(`QQ access_token 获取失败: HTTP ${response.status}`);
|
|
31
|
+
const ttl = Number(data.expires_in || 7200);
|
|
32
|
+
this.tokens.set(id, { token: data.access_token, expiresAt: Date.now() + Math.max(60, ttl) * 1000 });
|
|
33
|
+
return data.access_token;
|
|
34
|
+
}
|
|
35
|
+
async gatewayUrl(account) {
|
|
36
|
+
const token = await this.token(account);
|
|
37
|
+
const base = account.sandbox ? SANDBOX_API_BASE : API_BASE;
|
|
38
|
+
const response = await fetch(`${base}/gateway`, {
|
|
39
|
+
headers: { Authorization: `QQBot ${token}` }, signal: AbortSignal.timeout(15_000),
|
|
40
|
+
});
|
|
41
|
+
const data = await safeJson(response);
|
|
42
|
+
if (!response.ok || !data?.url)
|
|
43
|
+
throw new Error(`QQ gateway 获取失败: HTTP ${response.status}`);
|
|
44
|
+
return data.url;
|
|
45
|
+
}
|
|
46
|
+
nextSeq(messageId) {
|
|
47
|
+
const key = messageId || `active:${Date.now()}:${Math.random()}`;
|
|
48
|
+
const next = (this.sequences.get(key) || 0) + 1;
|
|
49
|
+
this.sequences.set(key, next);
|
|
50
|
+
return next;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* QQ stream_messages requires a fresh msg_seq for each stream session.
|
|
54
|
+
* It is intentionally not scoped by the inbound message id: using 1 for
|
|
55
|
+
* every reply causes QQ to reject later streams as duplicated messages.
|
|
56
|
+
*/
|
|
57
|
+
nextStreamSeq() {
|
|
58
|
+
this.streamSequence = (this.streamSequence + 1) & 0xffff;
|
|
59
|
+
return this.streamSequence;
|
|
60
|
+
}
|
|
61
|
+
async request(account, method, path, body, retry = true) {
|
|
62
|
+
const token = await this.token(account);
|
|
63
|
+
const base = account.sandbox ? SANDBOX_API_BASE : API_BASE;
|
|
64
|
+
const response = await fetch(`${base}${path}`, {
|
|
65
|
+
method,
|
|
66
|
+
headers: { Authorization: `QQBot ${token}`, 'content-type': 'application/json' },
|
|
67
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
68
|
+
signal: AbortSignal.timeout(20_000),
|
|
69
|
+
});
|
|
70
|
+
const data = await safeJson(response);
|
|
71
|
+
if (response.status === 401 && retry) {
|
|
72
|
+
this.clearToken(account.id);
|
|
73
|
+
return this.request(account, method, path, body, false);
|
|
74
|
+
}
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
const code = data?.code ? ` code=${String(data.code)}` : '';
|
|
77
|
+
const message = data?.message ? ` ${String(data.message)}` : '';
|
|
78
|
+
throw new Error(`QQ API HTTP ${response.status}${code}${message}`);
|
|
79
|
+
}
|
|
80
|
+
return (data || {});
|
|
81
|
+
}
|
|
82
|
+
async sendText(account, targetId, text, { group = false, messageId = null, format = this.config.replyFormat } = {}) {
|
|
83
|
+
const path = group ? `/v2/groups/${encodeURIComponent(targetId)}/messages` : `/v2/users/${encodeURIComponent(targetId)}/messages`;
|
|
84
|
+
const seq = this.nextSeq(messageId);
|
|
85
|
+
const useMarkdown = format === 'markdown' || (format === 'smart' && looksLikeMarkdown(text));
|
|
86
|
+
const makeBody = (markdown) => ({
|
|
87
|
+
msg_type: markdown ? 2 : 0,
|
|
88
|
+
...(markdown ? { markdown: { content: text } } : { content: text }),
|
|
89
|
+
msg_seq: seq,
|
|
90
|
+
...(messageId ? { msg_id: messageId } : {}),
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
return await this.request(account, 'POST', path, makeBody(useMarkdown));
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (useMarkdown && /50056|markdown|md perm/i.test(String(error))) {
|
|
97
|
+
return this.request(account, 'POST', path, makeBody(false));
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async sendReplyWithActiveFallback(account, targetId, text, options) {
|
|
103
|
+
try {
|
|
104
|
+
return await this.sendText(account, targetId, text, options);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (!options.messageId)
|
|
108
|
+
throw error;
|
|
109
|
+
return this.sendText(account, targetId, text, { ...options, messageId: null });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
createPrivateTextStream(account, targetId, { messageId = null, format = this.config.replyFormat } = {}) {
|
|
113
|
+
const path = `/v2/users/${encodeURIComponent(targetId)}/stream_messages`;
|
|
114
|
+
const msgSeq = this.nextStreamSeq();
|
|
115
|
+
const contentType = format === 'markdown' ? 'markdown' : 'text';
|
|
116
|
+
let fullText = '';
|
|
117
|
+
let streamMessageId;
|
|
118
|
+
let index = 0;
|
|
119
|
+
let sent = false;
|
|
120
|
+
let timer;
|
|
121
|
+
let chain = Promise.resolve();
|
|
122
|
+
let finished = false;
|
|
123
|
+
let lastSentText = '';
|
|
124
|
+
const enqueue = (inputState, content) => {
|
|
125
|
+
chain = chain.then(async () => {
|
|
126
|
+
// Build the request only when the queued task runs. The first
|
|
127
|
+
// response supplies stream_msg_id for all following frames.
|
|
128
|
+
const body = {
|
|
129
|
+
input_mode: 'replace', input_state: inputState, index: index++, content_type: contentType,
|
|
130
|
+
content_raw: content, msg_seq: msgSeq,
|
|
131
|
+
...(messageId ? { msg_id: messageId, event_id: messageId } : {}),
|
|
132
|
+
...(streamMessageId ? { stream_msg_id: streamMessageId } : {}),
|
|
133
|
+
};
|
|
134
|
+
let attempt = 0;
|
|
135
|
+
while (true) {
|
|
136
|
+
try {
|
|
137
|
+
const result = await this.request(account, 'POST', path, body);
|
|
138
|
+
sent = true;
|
|
139
|
+
if (!streamMessageId) {
|
|
140
|
+
const id = typeof result.stream_msg_id === 'string' ? result.stream_msg_id : result.id;
|
|
141
|
+
if (typeof id === 'string' && id)
|
|
142
|
+
streamMessageId = id;
|
|
143
|
+
}
|
|
144
|
+
lastSentText = content;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
const message = String(error);
|
|
149
|
+
const rateLimited = /HTTP 429|50002|rate limit/i.test(message);
|
|
150
|
+
if (!rateLimited || attempt >= 3)
|
|
151
|
+
throw error;
|
|
152
|
+
const delay = 1000 * (2 ** attempt++);
|
|
153
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
return chain;
|
|
158
|
+
};
|
|
159
|
+
const flush = (inputState = 1) => {
|
|
160
|
+
timer = undefined;
|
|
161
|
+
if (!fullText && inputState !== 10)
|
|
162
|
+
return chain;
|
|
163
|
+
if (inputState !== 10 && fullText === lastSentText)
|
|
164
|
+
return chain;
|
|
165
|
+
return enqueue(inputState, fullText);
|
|
166
|
+
};
|
|
167
|
+
return {
|
|
168
|
+
push: delta => {
|
|
169
|
+
if (!delta || finished)
|
|
170
|
+
return;
|
|
171
|
+
fullText += delta;
|
|
172
|
+
if (!timer) {
|
|
173
|
+
timer = setTimeout(() => { void flush().catch(() => { }); }, 500);
|
|
174
|
+
timer.unref?.();
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
finish: async (text) => {
|
|
178
|
+
finished = true;
|
|
179
|
+
if (timer)
|
|
180
|
+
clearTimeout(timer);
|
|
181
|
+
timer = undefined;
|
|
182
|
+
fullText = text;
|
|
183
|
+
await flush(1);
|
|
184
|
+
await chain;
|
|
185
|
+
await flush(10);
|
|
186
|
+
await chain;
|
|
187
|
+
},
|
|
188
|
+
hasSent: () => sent,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function safeJson(response) {
|
|
193
|
+
try {
|
|
194
|
+
return await response.json();
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return undefined;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
export function isReplyFormat(value) {
|
|
201
|
+
return value === 'smart' || value === 'markdown' || value === 'compat';
|
|
202
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { QQChatDatabase } from '../storage/db.js';
|
|
2
|
+
import type { QQChatConfig } from '../types.js';
|
|
3
|
+
export interface QQBindStartResult {
|
|
4
|
+
taskId: string;
|
|
5
|
+
scanUrl: string;
|
|
6
|
+
qrDataUrl: string;
|
|
7
|
+
expiresInMs: number;
|
|
8
|
+
}
|
|
9
|
+
export type QQBindPollResult = {
|
|
10
|
+
status: 'waiting';
|
|
11
|
+
} | {
|
|
12
|
+
status: 'expired';
|
|
13
|
+
} | {
|
|
14
|
+
status: 'fail';
|
|
15
|
+
reason: string;
|
|
16
|
+
} | {
|
|
17
|
+
status: 'success';
|
|
18
|
+
account: PublicAccount;
|
|
19
|
+
};
|
|
20
|
+
export interface PublicAccount {
|
|
21
|
+
id: number;
|
|
22
|
+
appId: string;
|
|
23
|
+
botUserId: string | null;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
sandbox: boolean;
|
|
26
|
+
gatewayStatus: string;
|
|
27
|
+
gatewayLastError: string | null;
|
|
28
|
+
}
|
|
29
|
+
export declare class QQBindService {
|
|
30
|
+
private readonly db;
|
|
31
|
+
private readonly config;
|
|
32
|
+
constructor(db: QQChatDatabase, config: QQChatConfig);
|
|
33
|
+
start(): Promise<QQBindStartResult>;
|
|
34
|
+
poll(taskId: string): Promise<QQBindPollResult>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import QRCode from 'qrcode';
|
|
3
|
+
import { decryptQQSecret } from './crypto.js';
|
|
4
|
+
const CREATE_URL = 'https://q.qq.com/lite/create_bind_task';
|
|
5
|
+
const POLL_URL = 'https://q.qq.com/lite/poll_bind_result';
|
|
6
|
+
const FRONTEND = 'https://q.qq.com/qqbot/openclaw/connect.html';
|
|
7
|
+
const TASK_TTL_MS = 600_000;
|
|
8
|
+
export class QQBindService {
|
|
9
|
+
db;
|
|
10
|
+
config;
|
|
11
|
+
constructor(db, config) {
|
|
12
|
+
this.db = db;
|
|
13
|
+
this.config = config;
|
|
14
|
+
}
|
|
15
|
+
async start() {
|
|
16
|
+
const aesKey = randomBytes(32).toString('base64');
|
|
17
|
+
const response = await fetch(CREATE_URL, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'content-type': 'application/json' },
|
|
20
|
+
body: JSON.stringify({ key: aesKey }),
|
|
21
|
+
signal: AbortSignal.timeout(15_000),
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
throw new Error(`QQ 创建连接任务失败: HTTP ${response.status}`);
|
|
25
|
+
const payload = await response.json();
|
|
26
|
+
if (payload.retcode !== 0)
|
|
27
|
+
throw new Error(`QQ 创建连接任务失败: ${payload.msg || 'unknown'}`);
|
|
28
|
+
const taskId = payload.data?.task_id;
|
|
29
|
+
if (!taskId)
|
|
30
|
+
throw new Error('QQ 未返回 task_id');
|
|
31
|
+
this.db.saveAuthTask(String(taskId), aesKey, TASK_TTL_MS);
|
|
32
|
+
const scanUrl = `${FRONTEND}?task_id=${encodeURIComponent(taskId)}&_wv=2&source=${encodeURIComponent(this.config.source)}`;
|
|
33
|
+
const qrDataUrl = await QRCode.toDataURL(scanUrl, { width: 224, margin: 1, errorCorrectionLevel: 'M' });
|
|
34
|
+
return { taskId: String(taskId), scanUrl, qrDataUrl, expiresInMs: TASK_TTL_MS };
|
|
35
|
+
}
|
|
36
|
+
async poll(taskId) {
|
|
37
|
+
const task = this.db.getAuthTask(taskId);
|
|
38
|
+
if (!task)
|
|
39
|
+
return { status: 'expired' };
|
|
40
|
+
const response = await fetch(POLL_URL, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ task_id: taskId }),
|
|
44
|
+
signal: AbortSignal.timeout(10_000),
|
|
45
|
+
});
|
|
46
|
+
if (!response.ok)
|
|
47
|
+
throw new Error(`QQ 轮询授权失败: HTTP ${response.status}`);
|
|
48
|
+
const payload = await response.json();
|
|
49
|
+
if (payload.retcode !== 0)
|
|
50
|
+
return { status: 'fail', reason: payload.msg || 'unknown' };
|
|
51
|
+
const data = payload.data || {};
|
|
52
|
+
if (data.status === 3) {
|
|
53
|
+
this.db.deleteAuthTask(taskId);
|
|
54
|
+
return { status: 'expired' };
|
|
55
|
+
}
|
|
56
|
+
if (data.status !== 2)
|
|
57
|
+
return { status: 'waiting' };
|
|
58
|
+
const appId = String(data.bot_appid || '');
|
|
59
|
+
const encrypted = String(data.bot_encrypt_secret || '');
|
|
60
|
+
if (!appId || !encrypted)
|
|
61
|
+
return { status: 'fail', reason: 'QQ 返回缺少 AppID 或 Secret' };
|
|
62
|
+
let secret;
|
|
63
|
+
try {
|
|
64
|
+
secret = decryptQQSecret(encrypted, task.aes_key);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return { status: 'fail', reason: 'QQ Secret 解密失败' };
|
|
68
|
+
}
|
|
69
|
+
const account = this.db.upsertAccount(appId, secret, this.config.sandbox);
|
|
70
|
+
this.db.deleteAuthTask(taskId);
|
|
71
|
+
return { status: 'success', account: publicAccount(account) };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function publicAccount(account) {
|
|
75
|
+
return {
|
|
76
|
+
id: Number(account.id), appId: account.app_id, botUserId: account.bot_user_id || null,
|
|
77
|
+
enabled: account.enabled === 1, sandbox: account.sandbox === 1,
|
|
78
|
+
gatewayStatus: account.gateway_status, gatewayLastError: account.gateway_last_error || null,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createDecipheriv } from 'node:crypto';
|
|
2
|
+
/** QQ bind_task returns base64(iv[12] + ciphertext + gcmTag[16]). */
|
|
3
|
+
export function decryptQQSecret(encryptedBase64, keyBase64) {
|
|
4
|
+
const key = Buffer.from(keyBase64, 'base64');
|
|
5
|
+
const raw = Buffer.from(encryptedBase64, 'base64');
|
|
6
|
+
if (key.length !== 32)
|
|
7
|
+
throw new Error('QQ bind AES key must be 32 bytes');
|
|
8
|
+
if (raw.length < 28)
|
|
9
|
+
throw new Error('QQ bind ciphertext is too short');
|
|
10
|
+
const iv = raw.subarray(0, 12);
|
|
11
|
+
const body = raw.subarray(12);
|
|
12
|
+
const tag = body.subarray(body.length - 16);
|
|
13
|
+
const ciphertext = body.subarray(0, body.length - 16);
|
|
14
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
15
|
+
decipher.setAuthTag(tag);
|
|
16
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
|
17
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { QQApiClient } from './api.js';
|
|
2
|
+
import type { QQChatDatabase } from '../storage/db.js';
|
|
3
|
+
import type { AccountRow, LoggerLike, QQNormalizedMessage } from '../types.js';
|
|
4
|
+
type QQMessageHandler = (message: QQNormalizedMessage) => Promise<void> | void;
|
|
5
|
+
export declare class QQGateway {
|
|
6
|
+
readonly account: AccountRow;
|
|
7
|
+
private readonly db;
|
|
8
|
+
private readonly api;
|
|
9
|
+
private readonly onMessage;
|
|
10
|
+
private readonly logger;
|
|
11
|
+
private stopped;
|
|
12
|
+
private ws?;
|
|
13
|
+
private sessionId?;
|
|
14
|
+
private lastSeq;
|
|
15
|
+
private heartbeatTimer?;
|
|
16
|
+
private ackTimer?;
|
|
17
|
+
private lastAckAt;
|
|
18
|
+
private loopPromise?;
|
|
19
|
+
private readonly dispatchChains;
|
|
20
|
+
constructor(account: AccountRow, db: QQChatDatabase, api: QQApiClient, onMessage: QQMessageHandler, logger?: LoggerLike);
|
|
21
|
+
start(): void;
|
|
22
|
+
stop(): Promise<void>;
|
|
23
|
+
private loop;
|
|
24
|
+
private connectOnce;
|
|
25
|
+
private handleFrame;
|
|
26
|
+
private handleDispatch;
|
|
27
|
+
private enqueueMessage;
|
|
28
|
+
private installHeartbeat;
|
|
29
|
+
private clearHeartbeat;
|
|
30
|
+
private send;
|
|
31
|
+
}
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { normalizeQQDispatch } from './normalize.js';
|
|
2
|
+
const OP = {
|
|
3
|
+
DISPATCH: 0,
|
|
4
|
+
HEARTBEAT: 1,
|
|
5
|
+
IDENTIFY: 2,
|
|
6
|
+
RESUME: 6,
|
|
7
|
+
RECONNECT: 7,
|
|
8
|
+
INVALID_SESSION: 9,
|
|
9
|
+
HELLO: 10,
|
|
10
|
+
HEARTBEAT_ACK: 11,
|
|
11
|
+
};
|
|
12
|
+
const INTENTS = 1 << 25;
|
|
13
|
+
const RECONNECT_DELAYS = [1000, 2000, 5000, 10_000, 30_000, 60_000];
|
|
14
|
+
export class QQGateway {
|
|
15
|
+
account;
|
|
16
|
+
db;
|
|
17
|
+
api;
|
|
18
|
+
onMessage;
|
|
19
|
+
logger;
|
|
20
|
+
stopped = false;
|
|
21
|
+
ws;
|
|
22
|
+
sessionId;
|
|
23
|
+
lastSeq = null;
|
|
24
|
+
heartbeatTimer;
|
|
25
|
+
ackTimer;
|
|
26
|
+
lastAckAt = 0;
|
|
27
|
+
loopPromise;
|
|
28
|
+
dispatchChains = new Map();
|
|
29
|
+
constructor(account, db, api, onMessage, logger = console) {
|
|
30
|
+
this.account = account;
|
|
31
|
+
this.db = db;
|
|
32
|
+
this.api = api;
|
|
33
|
+
this.onMessage = onMessage;
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
}
|
|
36
|
+
start() {
|
|
37
|
+
if (this.loopPromise)
|
|
38
|
+
return;
|
|
39
|
+
this.stopped = false;
|
|
40
|
+
this.loopPromise = this.loop().finally(() => { this.loopPromise = undefined; });
|
|
41
|
+
}
|
|
42
|
+
async stop() {
|
|
43
|
+
this.stopped = true;
|
|
44
|
+
this.clearHeartbeat();
|
|
45
|
+
try {
|
|
46
|
+
this.ws?.close();
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
try {
|
|
50
|
+
await this.loopPromise;
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
this.db.setAccountGateway(this.account.id, 'offline', null);
|
|
54
|
+
}
|
|
55
|
+
async loop() {
|
|
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
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (this.stopped)
|
|
65
|
+
break;
|
|
66
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
67
|
+
this.db.setAccountGateway(this.account.id, 'error', message.slice(0, 500));
|
|
68
|
+
this.logger.warn?.(`[dsh-qqchat] QQ gateway: ${message}`);
|
|
69
|
+
const delay = RECONNECT_DELAYS[Math.min(attempt++, RECONNECT_DELAYS.length - 1)] ?? RECONNECT_DELAYS.at(-1) ?? 60_000;
|
|
70
|
+
await sleep(delay);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async connectOnce() {
|
|
75
|
+
const [url, token] = await Promise.all([this.api.gatewayUrl(this.account), this.api.token(this.account)]);
|
|
76
|
+
await new Promise((resolve, reject) => {
|
|
77
|
+
const ws = new WebSocket(url);
|
|
78
|
+
this.ws = ws;
|
|
79
|
+
let settled = false;
|
|
80
|
+
const finish = (error) => {
|
|
81
|
+
if (settled)
|
|
82
|
+
return;
|
|
83
|
+
settled = true;
|
|
84
|
+
this.clearHeartbeat();
|
|
85
|
+
this.ws = undefined;
|
|
86
|
+
if (error)
|
|
87
|
+
reject(error);
|
|
88
|
+
else
|
|
89
|
+
resolve();
|
|
90
|
+
};
|
|
91
|
+
ws.addEventListener('error', () => finish(new Error('QQ WebSocket 连接错误')));
|
|
92
|
+
ws.addEventListener('close', event => {
|
|
93
|
+
if (this.stopped)
|
|
94
|
+
finish();
|
|
95
|
+
else
|
|
96
|
+
finish(new Error(`QQ WebSocket 已断开 (${event.code})`));
|
|
97
|
+
});
|
|
98
|
+
ws.addEventListener('message', event => {
|
|
99
|
+
this.handleFrame(ws, token, event.data).catch(error => {
|
|
100
|
+
try {
|
|
101
|
+
ws.close();
|
|
102
|
+
}
|
|
103
|
+
catch { }
|
|
104
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async handleFrame(ws, token, raw) {
|
|
110
|
+
const text = await asText(raw);
|
|
111
|
+
const frame = JSON.parse(text);
|
|
112
|
+
if (typeof frame.s === 'number')
|
|
113
|
+
this.lastSeq = frame.s;
|
|
114
|
+
switch (frame.op) {
|
|
115
|
+
case OP.HELLO: {
|
|
116
|
+
const interval = Number(frame.d?.heartbeat_interval || 45_000);
|
|
117
|
+
this.installHeartbeat(ws, interval);
|
|
118
|
+
if (this.sessionId) {
|
|
119
|
+
this.send(ws, OP.RESUME, { token: `QQBot ${token}`, session_id: this.sessionId, seq: this.lastSeq });
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
this.send(ws, OP.IDENTIFY, {
|
|
123
|
+
token: `QQBot ${token}`,
|
|
124
|
+
intents: INTENTS,
|
|
125
|
+
shard: [0, 1],
|
|
126
|
+
properties: { $os: process.platform, $browser: 'dsh-qqchat', $device: 'dsh-qqchat' },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case OP.HEARTBEAT_ACK:
|
|
132
|
+
this.lastAckAt = Date.now();
|
|
133
|
+
break;
|
|
134
|
+
case OP.RECONNECT:
|
|
135
|
+
ws.close(4000, 'server reconnect');
|
|
136
|
+
break;
|
|
137
|
+
case OP.INVALID_SESSION:
|
|
138
|
+
this.sessionId = undefined;
|
|
139
|
+
this.lastSeq = null;
|
|
140
|
+
ws.close(4001, 'invalid session');
|
|
141
|
+
break;
|
|
142
|
+
case OP.DISPATCH:
|
|
143
|
+
await this.handleDispatch(frame.t || '', frame.d || {});
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async handleDispatch(type, data) {
|
|
148
|
+
if (type === 'READY') {
|
|
149
|
+
const sessionId = data.session_id;
|
|
150
|
+
this.sessionId = typeof sessionId === 'string' && sessionId ? sessionId : this.sessionId;
|
|
151
|
+
const user = isRecord(data.user) ? data.user : undefined;
|
|
152
|
+
const botUserId = String(user?.id || user?.openid || '') || undefined;
|
|
153
|
+
this.db.setAccountGateway(this.account.id, 'online', null, botUserId);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (type === 'RESUMED') {
|
|
157
|
+
this.db.setAccountGateway(this.account.id, 'online', null);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const message = normalizeQQDispatch(type, data, Number(this.account.id));
|
|
161
|
+
if (message)
|
|
162
|
+
await this.enqueueMessage(message);
|
|
163
|
+
}
|
|
164
|
+
enqueueMessage(message) {
|
|
165
|
+
const key = `${this.account.id}:${message.chatType}:${message.chatId}`;
|
|
166
|
+
const previous = this.dispatchChains.get(key) || Promise.resolve();
|
|
167
|
+
const current = previous.catch(() => undefined).then(() => this.onMessage(message));
|
|
168
|
+
this.dispatchChains.set(key, current);
|
|
169
|
+
return current.finally(() => {
|
|
170
|
+
if (this.dispatchChains.get(key) === current)
|
|
171
|
+
this.dispatchChains.delete(key);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
installHeartbeat(ws, interval) {
|
|
175
|
+
this.clearHeartbeat();
|
|
176
|
+
this.lastAckAt = Date.now();
|
|
177
|
+
const tick = () => {
|
|
178
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
179
|
+
return;
|
|
180
|
+
if (Date.now() - this.lastAckAt > interval * 2.5) {
|
|
181
|
+
ws.close(4002, 'heartbeat timeout');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
this.send(ws, OP.HEARTBEAT, this.lastSeq);
|
|
185
|
+
};
|
|
186
|
+
this.heartbeatTimer = setInterval(tick, interval);
|
|
187
|
+
const firstTick = setTimeout(tick, Math.min(1000, interval));
|
|
188
|
+
firstTick.unref?.();
|
|
189
|
+
}
|
|
190
|
+
clearHeartbeat() {
|
|
191
|
+
if (this.heartbeatTimer)
|
|
192
|
+
clearInterval(this.heartbeatTimer);
|
|
193
|
+
if (this.ackTimer)
|
|
194
|
+
clearTimeout(this.ackTimer);
|
|
195
|
+
this.heartbeatTimer = undefined;
|
|
196
|
+
this.ackTimer = undefined;
|
|
197
|
+
}
|
|
198
|
+
send(ws, op, data) {
|
|
199
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
200
|
+
ws.send(JSON.stringify({ op, d: data }));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
async function asText(data) {
|
|
204
|
+
if (typeof data === 'string')
|
|
205
|
+
return data;
|
|
206
|
+
if (data instanceof ArrayBuffer)
|
|
207
|
+
return Buffer.from(data).toString('utf8');
|
|
208
|
+
if (ArrayBuffer.isView(data))
|
|
209
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8');
|
|
210
|
+
if (data instanceof Blob)
|
|
211
|
+
return data.text();
|
|
212
|
+
return String(data);
|
|
213
|
+
}
|
|
214
|
+
function isRecord(value) {
|
|
215
|
+
return typeof value === 'object' && value !== null;
|
|
216
|
+
}
|
|
217
|
+
function sleep(ms) {
|
|
218
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
219
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { QQDispatchData, QQNormalizedMessage } from '../types.js';
|
|
2
|
+
/** Replace QQ's stable mention tokens with the best known display name. */
|
|
3
|
+
export declare function renderQQMentionNames(text: string, data: QQDispatchData, lookup?: (platformUserId: string) => string | undefined): string;
|
|
4
|
+
export declare function messageMentionsBot(data: QQDispatchData, eventType: string): boolean;
|
|
5
|
+
export declare function botMentionId(data: QQDispatchData, eventType: string): string;
|
|
6
|
+
export declare function normalizeQQDispatch(eventType: string, data: QQDispatchData | undefined, accountId: number): QQNormalizedMessage | undefined;
|