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.
Files changed (45) hide show
  1. package/.env.example +58 -0
  2. package/LICENSE +21 -0
  3. package/README.md +453 -0
  4. package/bin/qq-codex-bridge.js +11 -0
  5. package/dist/apps/bridge-daemon/src/bootstrap.js +100 -0
  6. package/dist/apps/bridge-daemon/src/cli.js +141 -0
  7. package/dist/apps/bridge-daemon/src/config.js +109 -0
  8. package/dist/apps/bridge-daemon/src/debug-codex-workers.js +309 -0
  9. package/dist/apps/bridge-daemon/src/dev-launch.js +73 -0
  10. package/dist/apps/bridge-daemon/src/dev.js +28 -0
  11. package/dist/apps/bridge-daemon/src/http-server.js +36 -0
  12. package/dist/apps/bridge-daemon/src/main.js +57 -0
  13. package/dist/apps/bridge-daemon/src/thread-command-handler.js +197 -0
  14. package/dist/packages/adapters/codex-desktop/src/cdp-session.js +189 -0
  15. package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +1259 -0
  16. package/dist/packages/adapters/codex-desktop/src/composer-heuristics.js +11 -0
  17. package/dist/packages/adapters/codex-desktop/src/health.js +7 -0
  18. package/dist/packages/adapters/codex-desktop/src/reply-parser.js +10 -0
  19. package/dist/packages/adapters/qq/src/qq-api-client.js +232 -0
  20. package/dist/packages/adapters/qq/src/qq-channel-adapter.js +22 -0
  21. package/dist/packages/adapters/qq/src/qq-gateway-client.js +295 -0
  22. package/dist/packages/adapters/qq/src/qq-gateway-session-store.js +64 -0
  23. package/dist/packages/adapters/qq/src/qq-gateway.js +62 -0
  24. package/dist/packages/adapters/qq/src/qq-media-downloader.js +246 -0
  25. package/dist/packages/adapters/qq/src/qq-media-parser.js +144 -0
  26. package/dist/packages/adapters/qq/src/qq-normalizer.js +35 -0
  27. package/dist/packages/adapters/qq/src/qq-sender.js +241 -0
  28. package/dist/packages/adapters/qq/src/qq-stt.js +189 -0
  29. package/dist/packages/domain/src/driver.js +7 -0
  30. package/dist/packages/domain/src/message.js +7 -0
  31. package/dist/packages/domain/src/session.js +7 -0
  32. package/dist/packages/orchestrator/src/bridge-orchestrator.js +143 -0
  33. package/dist/packages/orchestrator/src/job-runner.js +5 -0
  34. package/dist/packages/orchestrator/src/media-context.js +90 -0
  35. package/dist/packages/orchestrator/src/qq-outbound-draft.js +38 -0
  36. package/dist/packages/orchestrator/src/qq-outbound-format.js +51 -0
  37. package/dist/packages/orchestrator/src/qqbot-skill-context.js +13 -0
  38. package/dist/packages/orchestrator/src/session-key.js +6 -0
  39. package/dist/packages/ports/src/conversation.js +1 -0
  40. package/dist/packages/ports/src/qq.js +1 -0
  41. package/dist/packages/ports/src/store.js +1 -0
  42. package/dist/packages/store/src/message-repo.js +53 -0
  43. package/dist/packages/store/src/session-repo.js +80 -0
  44. package/dist/packages/store/src/sqlite.js +64 -0
  45. 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,7 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export function ensureArtifactDir(baseDir) {
4
+ const artifactDir = path.join(baseDir, "artifacts", "desktop-driver");
5
+ fs.mkdirSync(artifactDir, { recursive: true });
6
+ return artifactDir;
7
+ }
@@ -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
+ }