qq-codex-bridge 0.1.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/.env.example +58 -0
- package/LICENSE +21 -0
- package/README.md +453 -0
- package/bin/qq-codex-bridge.js +11 -0
- package/dist/apps/bridge-daemon/src/bootstrap.js +100 -0
- package/dist/apps/bridge-daemon/src/cli.js +141 -0
- package/dist/apps/bridge-daemon/src/config.js +109 -0
- package/dist/apps/bridge-daemon/src/debug-codex-workers.js +309 -0
- package/dist/apps/bridge-daemon/src/dev-launch.js +73 -0
- package/dist/apps/bridge-daemon/src/dev.js +28 -0
- package/dist/apps/bridge-daemon/src/http-server.js +36 -0
- package/dist/apps/bridge-daemon/src/main.js +57 -0
- package/dist/apps/bridge-daemon/src/thread-command-handler.js +197 -0
- package/dist/packages/adapters/codex-desktop/src/cdp-session.js +189 -0
- package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +1259 -0
- package/dist/packages/adapters/codex-desktop/src/composer-heuristics.js +11 -0
- package/dist/packages/adapters/codex-desktop/src/health.js +7 -0
- package/dist/packages/adapters/codex-desktop/src/reply-parser.js +10 -0
- package/dist/packages/adapters/qq/src/qq-api-client.js +232 -0
- package/dist/packages/adapters/qq/src/qq-channel-adapter.js +22 -0
- package/dist/packages/adapters/qq/src/qq-gateway-client.js +295 -0
- package/dist/packages/adapters/qq/src/qq-gateway-session-store.js +64 -0
- package/dist/packages/adapters/qq/src/qq-gateway.js +62 -0
- package/dist/packages/adapters/qq/src/qq-media-downloader.js +246 -0
- package/dist/packages/adapters/qq/src/qq-media-parser.js +144 -0
- package/dist/packages/adapters/qq/src/qq-normalizer.js +35 -0
- package/dist/packages/adapters/qq/src/qq-sender.js +241 -0
- package/dist/packages/adapters/qq/src/qq-stt.js +189 -0
- package/dist/packages/domain/src/driver.js +7 -0
- package/dist/packages/domain/src/message.js +7 -0
- package/dist/packages/domain/src/session.js +7 -0
- package/dist/packages/orchestrator/src/bridge-orchestrator.js +143 -0
- package/dist/packages/orchestrator/src/job-runner.js +5 -0
- package/dist/packages/orchestrator/src/media-context.js +90 -0
- package/dist/packages/orchestrator/src/qq-outbound-draft.js +38 -0
- package/dist/packages/orchestrator/src/qq-outbound-format.js +51 -0
- package/dist/packages/orchestrator/src/qqbot-skill-context.js +13 -0
- package/dist/packages/orchestrator/src/session-key.js +6 -0
- package/dist/packages/ports/src/conversation.js +1 -0
- package/dist/packages/ports/src/qq.js +1 -0
- package/dist/packages/ports/src/store.js +1 -0
- package/dist/packages/store/src/message-repo.js +53 -0
- package/dist/packages/store/src/session-repo.js +80 -0
- package/dist/packages/store/src/sqlite.js +64 -0
- package/package.json +60 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function isLikelyComposerSubmitButton(candidate) {
|
|
2
|
+
const explicitSendLabel = /send|发送|submit|开始构建|构建|继续|resume|run/i;
|
|
3
|
+
const primaryComposerButtonClass = /\bsize-token-button-composer\b/i;
|
|
4
|
+
const filledPrimaryButtonClass = /\bbg-token-foreground\b/i;
|
|
5
|
+
const label = `${candidate.text} ${candidate.aria ?? ""} ${candidate.title ?? ""}`.trim();
|
|
6
|
+
if (explicitSendLabel.test(label)) {
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
return (primaryComposerButtonClass.test(candidate.className) &&
|
|
10
|
+
filledPrimaryButtonClass.test(candidate.className));
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function parseAssistantReply(snapshotText) {
|
|
2
|
+
const lines = snapshotText
|
|
3
|
+
.split("\n")
|
|
4
|
+
.map((line) => line.trim())
|
|
5
|
+
.filter(Boolean);
|
|
6
|
+
const assistantLines = lines
|
|
7
|
+
.filter((line) => line.startsWith("Assistant:"))
|
|
8
|
+
.map((line) => line.replace(/^Assistant:\s*/, ""));
|
|
9
|
+
return assistantLines.at(-1) ?? "";
|
|
10
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { MediaArtifactKind } from "../../../domain/src/message.js";
|
|
3
|
+
export class QqApiClient {
|
|
4
|
+
appId;
|
|
5
|
+
clientSecret;
|
|
6
|
+
authBaseUrl;
|
|
7
|
+
apiBaseUrl;
|
|
8
|
+
fetchFn;
|
|
9
|
+
now;
|
|
10
|
+
markdownSupport;
|
|
11
|
+
cachedToken = null;
|
|
12
|
+
msgSeqByReplyId = new Map();
|
|
13
|
+
constructor(appId, clientSecret, options = {}) {
|
|
14
|
+
this.appId = appId;
|
|
15
|
+
this.clientSecret = clientSecret;
|
|
16
|
+
this.authBaseUrl = options.authBaseUrl ?? "https://bots.qq.com";
|
|
17
|
+
this.apiBaseUrl = options.apiBaseUrl ?? "https://api.sgroup.qq.com";
|
|
18
|
+
this.fetchFn = options.fetchFn ?? fetch;
|
|
19
|
+
this.now = options.now ?? Date.now;
|
|
20
|
+
this.markdownSupport = options.markdownSupport ?? false;
|
|
21
|
+
}
|
|
22
|
+
async getAccessToken() {
|
|
23
|
+
if (this.cachedToken && this.cachedToken.expiresAt > this.now()) {
|
|
24
|
+
return this.cachedToken.value;
|
|
25
|
+
}
|
|
26
|
+
const response = await this.fetchFn(`${this.authBaseUrl}/app/getAppAccessToken`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: {
|
|
29
|
+
"content-type": "application/json"
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify({
|
|
32
|
+
appId: this.appId,
|
|
33
|
+
clientSecret: this.clientSecret
|
|
34
|
+
})
|
|
35
|
+
});
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
throw new Error(`QQ auth failed: ${response.status}`);
|
|
38
|
+
}
|
|
39
|
+
const payload = (await response.json());
|
|
40
|
+
const expiresIn = typeof payload.expires_in === "number"
|
|
41
|
+
? payload.expires_in
|
|
42
|
+
: typeof payload.expires_in === "string"
|
|
43
|
+
? Number(payload.expires_in)
|
|
44
|
+
: Number.NaN;
|
|
45
|
+
if (!payload.access_token || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
46
|
+
throw new Error("QQ auth response missing access token");
|
|
47
|
+
}
|
|
48
|
+
this.cachedToken = {
|
|
49
|
+
value: payload.access_token,
|
|
50
|
+
expiresAt: this.now() + Math.max(expiresIn - 60, 1) * 1000
|
|
51
|
+
};
|
|
52
|
+
return payload.access_token;
|
|
53
|
+
}
|
|
54
|
+
invalidateAccessToken() {
|
|
55
|
+
this.cachedToken = null;
|
|
56
|
+
}
|
|
57
|
+
async getGatewayUrl() {
|
|
58
|
+
const accessToken = await this.getAccessToken();
|
|
59
|
+
const response = await this.fetchFn(`${this.apiBaseUrl}/gateway`, {
|
|
60
|
+
method: "GET",
|
|
61
|
+
headers: {
|
|
62
|
+
authorization: `QQBot ${accessToken}`,
|
|
63
|
+
"content-type": "application/json"
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
throw new Error(`QQ gateway discovery failed: ${response.status}`);
|
|
68
|
+
}
|
|
69
|
+
const payload = (await response.json());
|
|
70
|
+
if (!payload.url) {
|
|
71
|
+
throw new Error("QQ gateway response missing websocket url");
|
|
72
|
+
}
|
|
73
|
+
return payload.url;
|
|
74
|
+
}
|
|
75
|
+
async sendC2CMessage(userOpenId, content, msgId, options = {}) {
|
|
76
|
+
return this.sendMessage(`/v2/users/${encodeURIComponent(userOpenId)}/messages`, content, msgId, options);
|
|
77
|
+
}
|
|
78
|
+
async sendGroupMessage(groupOpenId, content, msgId, options = {}) {
|
|
79
|
+
return this.sendMessage(`/v2/groups/${encodeURIComponent(groupOpenId)}/messages`, content, msgId, options);
|
|
80
|
+
}
|
|
81
|
+
async sendC2CMediaArtifact(userOpenId, artifact, msgId, content) {
|
|
82
|
+
return this.sendMediaArtifact(`/v2/users/${encodeURIComponent(userOpenId)}`, artifact, msgId, content);
|
|
83
|
+
}
|
|
84
|
+
async sendGroupMediaArtifact(groupOpenId, artifact, msgId, content) {
|
|
85
|
+
return this.sendMediaArtifact(`/v2/groups/${encodeURIComponent(groupOpenId)}`, artifact, msgId, content);
|
|
86
|
+
}
|
|
87
|
+
async sendMessage(path, content, msgId, options = {}) {
|
|
88
|
+
const accessToken = await this.getAccessToken();
|
|
89
|
+
const response = await this.fetchFn(`${this.apiBaseUrl}${path}`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: {
|
|
92
|
+
authorization: `QQBot ${accessToken}`,
|
|
93
|
+
"content-type": "application/json",
|
|
94
|
+
"X-Union-Appid": this.appId
|
|
95
|
+
},
|
|
96
|
+
body: JSON.stringify(this.buildMessageBody(content, msgId, options))
|
|
97
|
+
});
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
const responseText = await response.text().catch(() => "");
|
|
100
|
+
throw new Error(`QQ message send failed: ${response.status}${responseText ? ` ${responseText}` : ""}`);
|
|
101
|
+
}
|
|
102
|
+
const payload = (await response.json());
|
|
103
|
+
return payload.id ?? null;
|
|
104
|
+
}
|
|
105
|
+
async sendMediaArtifact(pathPrefix, artifact, msgId, content) {
|
|
106
|
+
this.assertSupportedMediaFormat(artifact);
|
|
107
|
+
const accessToken = await this.getAccessToken();
|
|
108
|
+
const uploadBody = await this.buildMediaUploadBody(artifact);
|
|
109
|
+
const uploadResponse = await this.fetchFn(`${this.apiBaseUrl}${pathPrefix}/files`, {
|
|
110
|
+
method: "POST",
|
|
111
|
+
headers: {
|
|
112
|
+
authorization: `QQBot ${accessToken}`,
|
|
113
|
+
"content-type": "application/json",
|
|
114
|
+
"X-Union-Appid": this.appId
|
|
115
|
+
},
|
|
116
|
+
body: JSON.stringify({
|
|
117
|
+
...uploadBody,
|
|
118
|
+
file_type: this.mapMediaFileType(artifact.kind),
|
|
119
|
+
srv_send_msg: false,
|
|
120
|
+
...(artifact.kind === MediaArtifactKind.File ? { file_name: artifact.originalName } : {})
|
|
121
|
+
})
|
|
122
|
+
});
|
|
123
|
+
if (!uploadResponse.ok) {
|
|
124
|
+
const responseText = await uploadResponse.text().catch(() => "");
|
|
125
|
+
throw new Error(`QQ media upload failed: ${uploadResponse.status}${responseText ? ` ${responseText}` : ""}`);
|
|
126
|
+
}
|
|
127
|
+
const uploadPayload = (await uploadResponse.json());
|
|
128
|
+
if (!uploadPayload.file_info) {
|
|
129
|
+
throw new Error("QQ media upload response missing file_info");
|
|
130
|
+
}
|
|
131
|
+
const response = await this.fetchFn(`${this.apiBaseUrl}${pathPrefix}/messages`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: {
|
|
134
|
+
authorization: `QQBot ${accessToken}`,
|
|
135
|
+
"content-type": "application/json",
|
|
136
|
+
"X-Union-Appid": this.appId
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
msg_type: 7,
|
|
140
|
+
media: { file_info: uploadPayload.file_info },
|
|
141
|
+
msg_seq: this.nextMsgSeq(msgId),
|
|
142
|
+
msg_id: msgId,
|
|
143
|
+
...(content ? { content } : {})
|
|
144
|
+
})
|
|
145
|
+
});
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
const responseText = await response.text().catch(() => "");
|
|
148
|
+
throw new Error(`QQ media message send failed: ${response.status}${responseText ? ` ${responseText}` : ""}`);
|
|
149
|
+
}
|
|
150
|
+
const payload = (await response.json());
|
|
151
|
+
return payload.id ?? null;
|
|
152
|
+
}
|
|
153
|
+
nextMsgSeq(msgId) {
|
|
154
|
+
const next = (this.msgSeqByReplyId.get(msgId) ?? 0) + 1;
|
|
155
|
+
this.msgSeqByReplyId.set(msgId, next);
|
|
156
|
+
return next;
|
|
157
|
+
}
|
|
158
|
+
buildMessageBody(content, msgId, options = {}) {
|
|
159
|
+
const msgSeq = this.nextMsgSeq(msgId);
|
|
160
|
+
const useMarkdown = this.markdownSupport || options.preferMarkdown === true;
|
|
161
|
+
if (useMarkdown) {
|
|
162
|
+
return {
|
|
163
|
+
markdown: { content },
|
|
164
|
+
msg_type: 2,
|
|
165
|
+
msg_seq: msgSeq,
|
|
166
|
+
msg_id: msgId
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
content,
|
|
171
|
+
msg_type: 0,
|
|
172
|
+
msg_seq: msgSeq,
|
|
173
|
+
msg_id: msgId
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
async buildMediaUploadBody(artifact) {
|
|
177
|
+
if (artifact.sourceUrl.startsWith("http://") || artifact.sourceUrl.startsWith("https://")) {
|
|
178
|
+
return { url: artifact.sourceUrl };
|
|
179
|
+
}
|
|
180
|
+
if (existsSync(artifact.localPath)) {
|
|
181
|
+
const stat = statSync(artifact.localPath);
|
|
182
|
+
const limitBytes = this.getFileSizeLimitBytes(artifact.kind);
|
|
183
|
+
if (stat.size > limitBytes) {
|
|
184
|
+
const limitMb = (limitBytes / 1024 / 1024).toFixed(0);
|
|
185
|
+
const actualMb = (stat.size / 1024 / 1024).toFixed(1);
|
|
186
|
+
throw new Error(`QQ media upload size limit exceeded: file is ${actualMb}MB but QQ allows at most ${limitMb}MB for ${artifact.kind} (file: ${artifact.originalName})`);
|
|
187
|
+
}
|
|
188
|
+
return { file_data: readFileSync(artifact.localPath).toString("base64") };
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`QQ media source not found: ${artifact.localPath}`);
|
|
191
|
+
}
|
|
192
|
+
getFileSizeLimitBytes(kind) {
|
|
193
|
+
switch (kind) {
|
|
194
|
+
case MediaArtifactKind.Image:
|
|
195
|
+
return 10 * 1024 * 1024; // 10 MB
|
|
196
|
+
case MediaArtifactKind.Video:
|
|
197
|
+
return 16 * 1024 * 1024; // 16 MB
|
|
198
|
+
case MediaArtifactKind.Audio:
|
|
199
|
+
return 16 * 1024 * 1024; // 16 MB
|
|
200
|
+
case MediaArtifactKind.File:
|
|
201
|
+
default:
|
|
202
|
+
return 30 * 1024 * 1024; // 30 MB
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
assertSupportedMediaFormat(artifact) {
|
|
206
|
+
const name = artifact.originalName || artifact.localPath || artifact.sourceUrl || "";
|
|
207
|
+
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
208
|
+
const SUPPORTED = {
|
|
209
|
+
[MediaArtifactKind.Image]: ["png", "jpg", "jpeg"],
|
|
210
|
+
[MediaArtifactKind.Video]: ["mp4"],
|
|
211
|
+
[MediaArtifactKind.Audio]: ["silk", "wav", "mp3", "flac"],
|
|
212
|
+
[MediaArtifactKind.File]: []
|
|
213
|
+
};
|
|
214
|
+
const allowed = SUPPORTED[artifact.kind];
|
|
215
|
+
if (allowed.length > 0 && !allowed.includes(ext)) {
|
|
216
|
+
throw new Error(`QQ media format not supported: .${ext} is not accepted for ${artifact.kind} (QQ only supports: ${allowed.join(", ")}). File: ${name}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
mapMediaFileType(kind) {
|
|
220
|
+
switch (kind) {
|
|
221
|
+
case MediaArtifactKind.Image:
|
|
222
|
+
return 1;
|
|
223
|
+
case MediaArtifactKind.Video:
|
|
224
|
+
return 2;
|
|
225
|
+
case MediaArtifactKind.Audio:
|
|
226
|
+
return 3;
|
|
227
|
+
case MediaArtifactKind.File:
|
|
228
|
+
default:
|
|
229
|
+
return 4;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { QqGatewayClient } from "./qq-gateway-client.js";
|
|
2
|
+
import { QqMediaDownloader } from "./qq-media-downloader.js";
|
|
3
|
+
import { QqSender } from "./qq-sender.js";
|
|
4
|
+
export function createQqChannelAdapter(config) {
|
|
5
|
+
return {
|
|
6
|
+
ingress: new QqGatewayClient({
|
|
7
|
+
accountKey: config.accountKey,
|
|
8
|
+
appId: config.appId,
|
|
9
|
+
apiClient: config.apiClient,
|
|
10
|
+
sessionStore: config.sessionStore,
|
|
11
|
+
...(config.mediaDownloadDir
|
|
12
|
+
? {
|
|
13
|
+
mediaDownloader: new QqMediaDownloader({
|
|
14
|
+
baseDir: config.mediaDownloadDir,
|
|
15
|
+
stt: config.stt
|
|
16
|
+
})
|
|
17
|
+
}
|
|
18
|
+
: {})
|
|
19
|
+
}),
|
|
20
|
+
egress: new QqSender(config.apiClient)
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { QqGateway } from "./qq-gateway.js";
|
|
3
|
+
const QQ_GATEWAY_INTENTS = (1 << 30) | // PUBLIC_GUILD_MESSAGES
|
|
4
|
+
(1 << 12) | // DIRECT_MESSAGE
|
|
5
|
+
(1 << 25) | // GROUP_AND_C2C
|
|
6
|
+
(1 << 26); // INTERACTION
|
|
7
|
+
const DEFAULT_RECONNECT_DELAYS_MS = [1000, 2000, 5000, 10000, 30000, 60000];
|
|
8
|
+
const RATE_LIMIT_DELAY_MS = 60000;
|
|
9
|
+
export class QqGatewayClient {
|
|
10
|
+
config;
|
|
11
|
+
gateway;
|
|
12
|
+
reconnectDelaysMs;
|
|
13
|
+
socket = null;
|
|
14
|
+
heartbeatTimer = null;
|
|
15
|
+
reconnectTimer = null;
|
|
16
|
+
currentSession;
|
|
17
|
+
currentAccessToken = null;
|
|
18
|
+
reconnectAttempt = 0;
|
|
19
|
+
startingPromise = null;
|
|
20
|
+
started = false;
|
|
21
|
+
shouldInvalidateToken = false;
|
|
22
|
+
nextReconnectDelayMs = null;
|
|
23
|
+
constructor(config) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.gateway = new QqGateway({
|
|
26
|
+
accountKey: config.accountKey,
|
|
27
|
+
mediaDownloader: config.mediaDownloader
|
|
28
|
+
});
|
|
29
|
+
this.reconnectDelaysMs = config.reconnectDelaysMs ?? DEFAULT_RECONNECT_DELAYS_MS;
|
|
30
|
+
this.currentSession = config.sessionStore.load();
|
|
31
|
+
}
|
|
32
|
+
async onMessage(handler) {
|
|
33
|
+
await this.gateway.onMessage(handler);
|
|
34
|
+
}
|
|
35
|
+
async start() {
|
|
36
|
+
this.started = true;
|
|
37
|
+
if (!this.startingPromise) {
|
|
38
|
+
this.startingPromise = this.connect().finally(() => {
|
|
39
|
+
this.startingPromise = null;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
await this.startingPromise;
|
|
43
|
+
}
|
|
44
|
+
async stop() {
|
|
45
|
+
this.started = false;
|
|
46
|
+
this.clearHeartbeat();
|
|
47
|
+
if (this.reconnectTimer) {
|
|
48
|
+
clearTimeout(this.reconnectTimer);
|
|
49
|
+
this.reconnectTimer = null;
|
|
50
|
+
}
|
|
51
|
+
const socket = this.socket;
|
|
52
|
+
this.socket = null;
|
|
53
|
+
if (!socket) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await new Promise((resolve) => {
|
|
57
|
+
socket.once("close", () => resolve());
|
|
58
|
+
socket.close();
|
|
59
|
+
setTimeout(resolve, 100);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
async connect() {
|
|
63
|
+
if (!this.started || this.socket) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (this.shouldInvalidateToken) {
|
|
67
|
+
this.config.apiClient.invalidateAccessToken?.();
|
|
68
|
+
this.shouldInvalidateToken = false;
|
|
69
|
+
}
|
|
70
|
+
const accessToken = await this.config.apiClient.getAccessToken();
|
|
71
|
+
this.currentAccessToken = accessToken;
|
|
72
|
+
const gatewayUrl = await this.config.apiClient.getGatewayUrl();
|
|
73
|
+
const socket = new WebSocket(gatewayUrl);
|
|
74
|
+
socket.on("message", (payload) => {
|
|
75
|
+
void this.handleSocketMessage(socket, payload.toString());
|
|
76
|
+
});
|
|
77
|
+
socket.on("close", (code) => {
|
|
78
|
+
this.onSocketClosed(socket, code);
|
|
79
|
+
});
|
|
80
|
+
socket.on("error", (error) => {
|
|
81
|
+
console.error("[qq-codex-bridge] qq gateway socket error", { error });
|
|
82
|
+
});
|
|
83
|
+
await this.waitForSocketOpen(socket);
|
|
84
|
+
if (!this.started) {
|
|
85
|
+
socket.close();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.socket = socket;
|
|
89
|
+
this.reconnectAttempt = 0;
|
|
90
|
+
}
|
|
91
|
+
async waitForSocketOpen(socket) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const cleanup = () => {
|
|
94
|
+
socket.off("open", handleOpen);
|
|
95
|
+
socket.off("error", handleError);
|
|
96
|
+
};
|
|
97
|
+
const handleOpen = () => {
|
|
98
|
+
cleanup();
|
|
99
|
+
resolve();
|
|
100
|
+
};
|
|
101
|
+
const handleError = (error) => {
|
|
102
|
+
cleanup();
|
|
103
|
+
reject(error);
|
|
104
|
+
};
|
|
105
|
+
socket.once("open", handleOpen);
|
|
106
|
+
socket.once("error", handleError);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async handleSocketMessage(socket, rawPayload) {
|
|
110
|
+
let payload;
|
|
111
|
+
try {
|
|
112
|
+
payload = JSON.parse(rawPayload);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
console.error("[qq-codex-bridge] qq gateway payload parse failed", { error, rawPayload });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (typeof payload.s === "number") {
|
|
119
|
+
this.updateLastSeq(payload.s);
|
|
120
|
+
}
|
|
121
|
+
switch (payload.op) {
|
|
122
|
+
case 10:
|
|
123
|
+
await this.handleHello(socket, payload);
|
|
124
|
+
return;
|
|
125
|
+
case 0:
|
|
126
|
+
await this.handleDispatch(payload);
|
|
127
|
+
return;
|
|
128
|
+
case 7:
|
|
129
|
+
this.nextReconnectDelayMs = 0;
|
|
130
|
+
socket.close();
|
|
131
|
+
return;
|
|
132
|
+
case 9:
|
|
133
|
+
if (payload.d !== true) {
|
|
134
|
+
this.clearSession();
|
|
135
|
+
this.shouldInvalidateToken = true;
|
|
136
|
+
}
|
|
137
|
+
this.nextReconnectDelayMs = 3000;
|
|
138
|
+
socket.close();
|
|
139
|
+
return;
|
|
140
|
+
case 11:
|
|
141
|
+
return;
|
|
142
|
+
default:
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async handleHello(socket, payload) {
|
|
147
|
+
const accessToken = this.currentAccessToken;
|
|
148
|
+
if (!accessToken) {
|
|
149
|
+
throw new Error("qq gateway access token not initialized");
|
|
150
|
+
}
|
|
151
|
+
const heartbeatInterval = this.readHeartbeatInterval(payload.d);
|
|
152
|
+
if (heartbeatInterval === null) {
|
|
153
|
+
throw new Error("qq gateway hello payload missing heartbeat interval");
|
|
154
|
+
}
|
|
155
|
+
if (this.currentSession) {
|
|
156
|
+
socket.send(JSON.stringify({
|
|
157
|
+
op: 6,
|
|
158
|
+
d: {
|
|
159
|
+
token: `QQBot ${accessToken}`,
|
|
160
|
+
session_id: this.currentSession.sessionId,
|
|
161
|
+
seq: this.currentSession.lastSeq
|
|
162
|
+
}
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
socket.send(JSON.stringify({
|
|
167
|
+
op: 2,
|
|
168
|
+
d: {
|
|
169
|
+
token: `QQBot ${accessToken}`,
|
|
170
|
+
intents: QQ_GATEWAY_INTENTS,
|
|
171
|
+
shard: [0, 1]
|
|
172
|
+
}
|
|
173
|
+
}));
|
|
174
|
+
}
|
|
175
|
+
this.startHeartbeat(heartbeatInterval);
|
|
176
|
+
}
|
|
177
|
+
async handleDispatch(payload) {
|
|
178
|
+
const dispatchData = this.readDispatchData(payload.d);
|
|
179
|
+
if (payload.t === "READY") {
|
|
180
|
+
const sessionId = typeof dispatchData?.session_id === "string" ? dispatchData.session_id : null;
|
|
181
|
+
if (sessionId) {
|
|
182
|
+
this.currentSession = {
|
|
183
|
+
sessionId,
|
|
184
|
+
lastSeq: payload.s ?? this.currentSession?.lastSeq ?? 0
|
|
185
|
+
};
|
|
186
|
+
this.config.sessionStore.save(this.currentSession);
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (payload.t === "RESUMED") {
|
|
191
|
+
if (this.currentSession) {
|
|
192
|
+
this.config.sessionStore.save(this.currentSession);
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (dispatchData
|
|
197
|
+
&& (payload.t === "C2C_MESSAGE_CREATE"
|
|
198
|
+
|| payload.t === "GROUP_AT_MESSAGE_CREATE"
|
|
199
|
+
|| payload.t === "GROUP_MESSAGE_CREATE")) {
|
|
200
|
+
await this.gateway.dispatchPayload({
|
|
201
|
+
t: payload.t,
|
|
202
|
+
d: dispatchData
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
startHeartbeat(intervalMs) {
|
|
207
|
+
this.clearHeartbeat();
|
|
208
|
+
this.heartbeatTimer = setInterval(() => {
|
|
209
|
+
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
this.socket.send(JSON.stringify({
|
|
213
|
+
op: 1,
|
|
214
|
+
d: this.currentSession?.lastSeq ?? null
|
|
215
|
+
}));
|
|
216
|
+
}, intervalMs);
|
|
217
|
+
}
|
|
218
|
+
clearHeartbeat() {
|
|
219
|
+
if (this.heartbeatTimer) {
|
|
220
|
+
clearInterval(this.heartbeatTimer);
|
|
221
|
+
this.heartbeatTimer = null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
onSocketClosed(socket, code) {
|
|
225
|
+
if (this.socket !== socket) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.socket = null;
|
|
229
|
+
this.clearHeartbeat();
|
|
230
|
+
if (!this.started) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
let delay = this.nextReconnectDelayMs ?? this.reconnectDelaysMs[Math.min(this.reconnectAttempt, this.reconnectDelaysMs.length - 1)];
|
|
234
|
+
this.nextReconnectDelayMs = null;
|
|
235
|
+
if (code === 4914 || code === 4915) {
|
|
236
|
+
console.error("[qq-codex-bridge] qq gateway terminated permanently", { code });
|
|
237
|
+
this.started = false;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (code === 4004) {
|
|
241
|
+
this.shouldInvalidateToken = true;
|
|
242
|
+
}
|
|
243
|
+
else if (code === 4008) {
|
|
244
|
+
delay = RATE_LIMIT_DELAY_MS;
|
|
245
|
+
}
|
|
246
|
+
else if (code === 4006
|
|
247
|
+
|| code === 4007
|
|
248
|
+
|| code === 4009
|
|
249
|
+
|| (code >= 4900 && code <= 4913)) {
|
|
250
|
+
this.clearSession();
|
|
251
|
+
this.shouldInvalidateToken = true;
|
|
252
|
+
}
|
|
253
|
+
this.scheduleReconnect(delay);
|
|
254
|
+
}
|
|
255
|
+
scheduleReconnect(delayMs) {
|
|
256
|
+
if (!this.started || this.reconnectTimer) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.reconnectTimer = setTimeout(() => {
|
|
260
|
+
this.reconnectTimer = null;
|
|
261
|
+
this.reconnectAttempt += 1;
|
|
262
|
+
void this.connect().catch((error) => {
|
|
263
|
+
console.error("[qq-codex-bridge] qq gateway reconnect failed", { error });
|
|
264
|
+
this.scheduleReconnect(this.reconnectDelaysMs[Math.min(this.reconnectAttempt, this.reconnectDelaysMs.length - 1)]);
|
|
265
|
+
});
|
|
266
|
+
}, delayMs);
|
|
267
|
+
}
|
|
268
|
+
updateLastSeq(lastSeq) {
|
|
269
|
+
if (!this.currentSession) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.currentSession = {
|
|
273
|
+
...this.currentSession,
|
|
274
|
+
lastSeq
|
|
275
|
+
};
|
|
276
|
+
this.config.sessionStore.save(this.currentSession);
|
|
277
|
+
}
|
|
278
|
+
clearSession() {
|
|
279
|
+
this.currentSession = null;
|
|
280
|
+
this.config.sessionStore.clear();
|
|
281
|
+
}
|
|
282
|
+
readHeartbeatInterval(payload) {
|
|
283
|
+
if (!payload || typeof payload !== "object") {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
const value = payload.heartbeat_interval;
|
|
287
|
+
return typeof value === "number" ? value : null;
|
|
288
|
+
}
|
|
289
|
+
readDispatchData(payload) {
|
|
290
|
+
if (!payload || typeof payload !== "object") {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
return payload;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
export class FileQqGatewaySessionStore {
|
|
4
|
+
filePath;
|
|
5
|
+
accountKey;
|
|
6
|
+
appId;
|
|
7
|
+
now;
|
|
8
|
+
maxAgeMs;
|
|
9
|
+
constructor(filePath, accountKey, appId, options = {}) {
|
|
10
|
+
this.filePath = filePath;
|
|
11
|
+
this.accountKey = accountKey;
|
|
12
|
+
this.appId = appId;
|
|
13
|
+
this.now = options.now ?? Date.now;
|
|
14
|
+
this.maxAgeMs = options.maxAgeMs ?? 5 * 60 * 1000;
|
|
15
|
+
}
|
|
16
|
+
load() {
|
|
17
|
+
try {
|
|
18
|
+
const payload = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
19
|
+
const now = this.now();
|
|
20
|
+
if (payload.accountKey !== this.accountKey || payload.appId !== this.appId) {
|
|
21
|
+
this.clear();
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (typeof payload.sessionId !== "string"
|
|
25
|
+
|| typeof payload.lastSeq !== "number"
|
|
26
|
+
|| typeof payload.savedAt !== "number") {
|
|
27
|
+
this.clear();
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (now - payload.savedAt > this.maxAgeMs) {
|
|
31
|
+
this.clear();
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
sessionId: payload.sessionId,
|
|
36
|
+
lastSeq: payload.lastSeq
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
save(state) {
|
|
44
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
45
|
+
const now = this.now();
|
|
46
|
+
const payload = {
|
|
47
|
+
accountKey: this.accountKey,
|
|
48
|
+
appId: this.appId,
|
|
49
|
+
sessionId: state.sessionId,
|
|
50
|
+
lastSeq: state.lastSeq,
|
|
51
|
+
lastConnectedAt: now,
|
|
52
|
+
savedAt: now
|
|
53
|
+
};
|
|
54
|
+
writeFileSync(this.filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
55
|
+
}
|
|
56
|
+
clear() {
|
|
57
|
+
try {
|
|
58
|
+
rmSync(this.filePath, { force: true });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// ignore cleanup failures
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|