qq-codex-bridge 0.1.3 → 0.1.4

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 (39) hide show
  1. package/.env.example +62 -0
  2. package/README.md +232 -287
  3. package/bin/chatgpt-desktop.js +2 -0
  4. package/bin/qq-codex-weixin-gateway.js +14 -0
  5. package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
  6. package/dist/apps/bridge-daemon/src/cli.js +5 -1
  7. package/dist/apps/bridge-daemon/src/config.js +168 -37
  8. package/dist/apps/bridge-daemon/src/http-server.js +23 -11
  9. package/dist/apps/bridge-daemon/src/main.js +163 -29
  10. package/dist/apps/bridge-daemon/src/thread-command-handler.js +309 -26
  11. package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
  12. package/dist/apps/weixin-gateway/src/cli.js +446 -0
  13. package/dist/apps/weixin-gateway/src/config.js +135 -0
  14. package/dist/apps/weixin-gateway/src/dev.js +2 -0
  15. package/dist/apps/weixin-gateway/src/message-store.js +50 -0
  16. package/dist/apps/weixin-gateway/src/server.js +216 -0
  17. package/dist/apps/weixin-gateway/src/state.js +163 -0
  18. package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
  19. package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
  20. package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
  21. package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
  22. package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
  23. package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
  24. package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
  25. package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
  26. package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
  27. package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
  28. package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
  29. package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
  30. package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
  31. package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
  32. package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
  33. package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
  34. package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
  35. package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
  36. package/dist/packages/ports/src/chat.js +1 -0
  37. package/dist/packages/store/src/session-repo.js +16 -3
  38. package/dist/packages/store/src/sqlite.js +3 -0
  39. package/package.json +8 -2
@@ -2,13 +2,14 @@ import { randomUUID } from "node:crypto";
2
2
  import { BridgeSessionStatus } from "../../domain/src/session.js";
3
3
  import { TurnEventType } from "../../domain/src/message.js";
4
4
  import { DesktopDriverError } from "../../domain/src/driver.js";
5
- import { formatQqOutboundDraft } from "./qq-outbound-format.js";
6
5
  export class BridgeOrchestrator {
7
6
  deps;
8
7
  recentInboundFingerprints = new Map();
9
8
  turnStates = new Map();
9
+ draftFormatter;
10
10
  constructor(deps) {
11
11
  this.deps = deps;
12
+ this.draftFormatter = deps.draftFormatter ?? ((draft) => draft);
12
13
  }
13
14
  async handleInbound(message) {
14
15
  const alreadySeen = await this.deps.transcriptStore.hasInbound(message.messageId);
@@ -36,7 +37,9 @@ export class BridgeOrchestrator {
36
37
  chatType: message.chatType,
37
38
  peerId: message.senderId,
38
39
  codexThreadRef: null,
40
+ lastCodexTurnId: null,
39
41
  skillContextKey: null,
42
+ conversationProvider: null,
40
43
  status: BridgeSessionStatus.Active,
41
44
  lastInboundAt: message.receivedAt,
42
45
  lastOutboundAt: null,
@@ -54,18 +57,25 @@ export class BridgeOrchestrator {
54
57
  return;
55
58
  }
56
59
  deliveredDraftIds.add(draft.draftId);
57
- await this.deps.transcriptStore.recordOutbound(draft);
60
+ if (draft.turnId) {
61
+ await this.deps.sessionStore.updateLastCodexTurnId(message.sessionKey, draft.turnId);
62
+ }
63
+ const formattedDraft = this.draftFormatter(draft);
64
+ if (isEmptyDraft(formattedDraft)) {
65
+ return;
66
+ }
67
+ await this.deps.transcriptStore.recordOutbound(formattedDraft);
58
68
  try {
59
- await this.deps.qqEgress.deliver(draft);
60
- this.recordDeliveredDraft(draft);
69
+ await this.deps.qqEgress.deliver(formattedDraft);
70
+ this.recordDeliveredDraft(formattedDraft);
61
71
  }
62
72
  catch (error) {
63
73
  const reason = error instanceof Error ? error.message : String(error);
64
- deliveryErrors.push(`${draft.draftId}: ${reason}`);
74
+ deliveryErrors.push(`${formattedDraft.draftId}: ${reason}`);
65
75
  console.warn("[qq-codex-bridge] draft delivery failed", {
66
76
  sessionKey: message.sessionKey,
67
77
  messageId: message.messageId,
68
- draftId: draft.draftId,
78
+ draftId: formattedDraft.draftId,
69
79
  error: reason
70
80
  });
71
81
  }
@@ -100,6 +110,7 @@ export class BridgeOrchestrator {
100
110
  if (event.sequence <= state.lastSequence) {
101
111
  return;
102
112
  }
113
+ await this.deps.sessionStore.updateLastCodexTurnId(event.sessionKey, event.turnId);
103
114
  state.lastSequence = event.sequence;
104
115
  state.lastEventAt = event.createdAt;
105
116
  if (typeof event.payload.fullText === "string") {
@@ -117,7 +128,7 @@ export class BridgeOrchestrator {
117
128
  state.finalFlushed = true;
118
129
  return;
119
130
  }
120
- const draft = formatQqOutboundDraft({
131
+ const draft = this.draftFormatter({
121
132
  draftId: randomUUID(),
122
133
  turnId: event.turnId,
123
134
  sessionKey: event.sessionKey,
@@ -127,48 +138,65 @@ export class BridgeOrchestrator {
127
138
  ? { replyToMessageId: event.payload.replyToMessageId }
128
139
  : {})
129
140
  });
130
- if (!draft.text.trim()) {
141
+ const pendingArtifacts = filterPendingArtifacts(draft.mediaArtifacts ?? [], state.sentArtifactKeys);
142
+ const normalizedDraft = pendingArtifacts.length === (draft.mediaArtifacts?.length ?? 0)
143
+ ? draft
144
+ : {
145
+ ...draft,
146
+ mediaArtifacts: pendingArtifacts
147
+ };
148
+ if (isEmptyDraft(normalizedDraft)) {
131
149
  state.sentText = state.assembledText;
132
150
  state.completed = true;
133
151
  state.finalFlushed = true;
134
152
  return;
135
153
  }
136
- await this.deps.transcriptStore.recordOutbound(draft);
137
- await this.deps.qqEgress.deliver(draft);
154
+ await this.deps.transcriptStore.recordOutbound(normalizedDraft);
155
+ await this.deps.qqEgress.deliver(normalizedDraft);
156
+ this.recordDeliveredDraft(normalizedDraft);
138
157
  state.sentText = state.assembledText;
139
158
  state.completed = true;
140
159
  state.finalFlushed = true;
141
160
  });
142
161
  }
143
162
  isLikelyDuplicateInbound(message) {
144
- const record = this.recentInboundFingerprints.get(message.sessionKey);
145
- if (!record) {
146
- return false;
147
- }
148
163
  const receivedAtMs = Date.parse(message.receivedAt);
149
164
  if (!Number.isFinite(receivedAtMs)) {
150
165
  return false;
151
166
  }
152
- return (record.fingerprint === buildInboundFingerprint(message) &&
153
- receivedAtMs - record.receivedAtMs >= 0 &&
154
- receivedAtMs - record.receivedAtMs <= 90_000);
167
+ const records = this.getRecentInboundRecords(message.sessionKey, receivedAtMs);
168
+ if (!records.length) {
169
+ return false;
170
+ }
171
+ const fingerprint = buildInboundFingerprint(message);
172
+ return records.some((record) => record.fingerprint === fingerprint
173
+ && receivedAtMs - record.receivedAtMs >= 0
174
+ && receivedAtMs - record.receivedAtMs <= 90_000);
155
175
  }
156
176
  rememberInbound(message) {
157
177
  const receivedAtMs = Date.parse(message.receivedAt);
158
178
  if (!Number.isFinite(receivedAtMs)) {
159
179
  return;
160
180
  }
161
- const now = receivedAtMs;
162
- for (const [sessionKey, record] of this.recentInboundFingerprints.entries()) {
163
- if (now - record.receivedAtMs > 120_000) {
164
- this.recentInboundFingerprints.delete(sessionKey);
165
- }
166
- }
167
- this.recentInboundFingerprints.set(message.sessionKey, {
181
+ const records = this.getRecentInboundRecords(message.sessionKey, receivedAtMs);
182
+ records.push({
168
183
  fingerprint: buildInboundFingerprint(message),
169
184
  receivedAtMs,
170
185
  messageId: message.messageId
171
186
  });
187
+ this.recentInboundFingerprints.set(message.sessionKey, records);
188
+ }
189
+ getRecentInboundRecords(sessionKey, referenceTimeMs) {
190
+ for (const [key, records] of this.recentInboundFingerprints.entries()) {
191
+ const retained = records.filter((record) => referenceTimeMs - record.receivedAtMs <= 120_000);
192
+ if (retained.length > 0) {
193
+ this.recentInboundFingerprints.set(key, retained);
194
+ }
195
+ else {
196
+ this.recentInboundFingerprints.delete(key);
197
+ }
198
+ }
199
+ return [...(this.recentInboundFingerprints.get(sessionKey) ?? [])];
172
200
  }
173
201
  getOrCreateTurnState(event) {
174
202
  const key = buildTurnStateKey(event.sessionKey, event.turnId);
@@ -180,6 +208,7 @@ export class BridgeOrchestrator {
180
208
  lastSequence: 0,
181
209
  assembledText: "",
182
210
  sentText: "",
211
+ sentArtifactKeys: new Set(),
183
212
  lastEventAt: null,
184
213
  completed: false,
185
214
  finalFlushed: false
@@ -188,7 +217,7 @@ export class BridgeOrchestrator {
188
217
  return created;
189
218
  }
190
219
  recordDeliveredDraft(draft) {
191
- if (!draft.turnId || !draft.text) {
220
+ if (!draft.turnId) {
192
221
  return;
193
222
  }
194
223
  const key = buildTurnStateKey(draft.sessionKey, draft.turnId);
@@ -196,11 +225,15 @@ export class BridgeOrchestrator {
196
225
  lastSequence: 0,
197
226
  assembledText: "",
198
227
  sentText: "",
228
+ sentArtifactKeys: new Set(),
199
229
  lastEventAt: null,
200
230
  completed: false,
201
231
  finalFlushed: false
202
232
  };
203
233
  state.sentText += draft.text;
234
+ for (const artifact of draft.mediaArtifacts ?? []) {
235
+ state.sentArtifactKeys.add(buildArtifactKey(artifact));
236
+ }
204
237
  this.turnStates.set(key, state);
205
238
  }
206
239
  }
@@ -259,3 +292,17 @@ function findSuffixPrefixOverlap(previous, next) {
259
292
  function stripWhitespace(value) {
260
293
  return value.replace(/\s+/g, "");
261
294
  }
295
+ function filterPendingArtifacts(artifacts, sentArtifactKeys) {
296
+ return artifacts.filter((artifact) => !sentArtifactKeys.has(buildArtifactKey(artifact)));
297
+ }
298
+ function buildArtifactKey(artifact) {
299
+ return [
300
+ artifact.kind,
301
+ artifact.localPath || "",
302
+ artifact.sourceUrl || "",
303
+ artifact.originalName || ""
304
+ ].join("::");
305
+ }
306
+ function isEmptyDraft(draft) {
307
+ return !draft.text.trim() && !(draft.mediaArtifacts?.length);
308
+ }
@@ -0,0 +1,55 @@
1
+ export function formatWeixinOutboundDraft(draft) {
2
+ const formattedText = formatWeixinOutboundText(draft.text, draft.mediaArtifacts ?? []);
3
+ if (formattedText === draft.text) {
4
+ return draft;
5
+ }
6
+ return {
7
+ ...draft,
8
+ text: formattedText
9
+ };
10
+ }
11
+ export function formatWeixinOutboundText(text, mediaArtifacts) {
12
+ if (!text) {
13
+ return text;
14
+ }
15
+ const strippedMediaBlocks = text.replace(/<qqmedia>[\s\S]*?<\/qqmedia>/g, "");
16
+ const hasMediaArtifacts = mediaArtifacts.length > 0;
17
+ const pathSet = new Set(mediaArtifacts.flatMap((artifact) => [artifact.localPath, artifact.sourceUrl].filter((value) => typeof value === "string" && value.trim().length > 0)));
18
+ const normalizedLines = strippedMediaBlocks
19
+ .split("\n")
20
+ .filter((line) => {
21
+ const trimmed = line.trim();
22
+ if (!trimmed) {
23
+ return false;
24
+ }
25
+ if (trimmed.includes("<qqmedia>") || trimmed.includes("</qqmedia>")) {
26
+ return false;
27
+ }
28
+ if (pathSet.has(stripWrapping(trimmed))) {
29
+ return false;
30
+ }
31
+ if (containsOnlyArtifactPath(trimmed, pathSet)) {
32
+ return false;
33
+ }
34
+ if (hasMediaArtifacts && shouldDropInternalBridgeLine(trimmed)) {
35
+ return false;
36
+ }
37
+ return true;
38
+ });
39
+ return normalizedLines.join("\n").trim();
40
+ }
41
+ function containsOnlyArtifactPath(line, paths) {
42
+ const normalized = stripWrapping(line);
43
+ return Array.from(paths).some((path) => normalized === stripWrapping(path));
44
+ }
45
+ function stripWrapping(value) {
46
+ return value.replace(/^[-*]\s*/, "").replace(/^`+/, "").replace(/`+$/, "").trim();
47
+ }
48
+ function shouldDropInternalBridgeLine(line) {
49
+ return (line.includes("QQBot 桥接程序收到你上传附件后") ||
50
+ line.includes("临时落盘的运行目录路径") ||
51
+ line.includes("这里看到的是相对路径") ||
52
+ line.includes("runtime/media/") ||
53
+ line.includes("/Volumes/") ||
54
+ /^[A-Za-z]:[\\/]/.test(line));
55
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -13,7 +13,9 @@ export class SqliteSessionStore {
13
13
  chat_type AS chatType,
14
14
  peer_id AS peerId,
15
15
  codex_thread_ref AS codexThreadRef,
16
+ last_codex_turn_id AS lastCodexTurnId,
16
17
  skill_context_key AS skillContextKey,
18
+ conversation_provider AS conversationProvider,
17
19
  status,
18
20
  last_inbound_at AS lastInboundAt,
19
21
  last_outbound_at AS lastOutboundAt,
@@ -27,9 +29,10 @@ export class SqliteSessionStore {
27
29
  this.db
28
30
  .prepare(`INSERT INTO bridge_sessions (
29
31
  session_key, account_key, peer_key, chat_type, peer_id,
30
- codex_thread_ref, skill_context_key, status, last_inbound_at, last_outbound_at, last_error
31
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
32
- .run(session.sessionKey, session.accountKey, session.peerKey, session.chatType, session.peerId, session.codexThreadRef, session.skillContextKey, session.status, session.lastInboundAt, session.lastOutboundAt, session.lastError);
32
+ codex_thread_ref, last_codex_turn_id, skill_context_key, status,
33
+ last_inbound_at, last_outbound_at, last_error
34
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
35
+ .run(session.sessionKey, session.accountKey, session.peerKey, session.chatType, session.peerId, session.codexThreadRef, session.lastCodexTurnId, session.skillContextKey, session.status, session.lastInboundAt, session.lastOutboundAt, session.lastError);
33
36
  }
34
37
  async updateSessionStatus(sessionKey, status, lastError = null) {
35
38
  this.db
@@ -41,11 +44,21 @@ export class SqliteSessionStore {
41
44
  .prepare(`UPDATE bridge_sessions SET codex_thread_ref = ? WHERE session_key = ?`)
42
45
  .run(codexThreadRef, sessionKey);
43
46
  }
47
+ async updateLastCodexTurnId(sessionKey, lastCodexTurnId) {
48
+ this.db
49
+ .prepare(`UPDATE bridge_sessions SET last_codex_turn_id = ? WHERE session_key = ?`)
50
+ .run(lastCodexTurnId, sessionKey);
51
+ }
44
52
  async updateSkillContextKey(sessionKey, skillContextKey) {
45
53
  this.db
46
54
  .prepare(`UPDATE bridge_sessions SET skill_context_key = ? WHERE session_key = ?`)
47
55
  .run(skillContextKey, sessionKey);
48
56
  }
57
+ async updateConversationProvider(sessionKey, provider) {
58
+ this.db
59
+ .prepare(`UPDATE bridge_sessions SET conversation_provider = ? WHERE session_key = ?`)
60
+ .run(provider, sessionKey);
61
+ }
49
62
  async withSessionLock(sessionKey, work) {
50
63
  // Queue same-session work in-process so the SQLite lock row reflects a real exclusive section.
51
64
  const previousTail = this.sessionLockTails.get(sessionKey) ?? Promise.resolve();
@@ -16,6 +16,7 @@ export function createSqliteDatabase(filePath) {
16
16
  chat_type TEXT NOT NULL,
17
17
  peer_id TEXT NOT NULL,
18
18
  codex_thread_ref TEXT,
19
+ last_codex_turn_id TEXT,
19
20
  skill_context_key TEXT,
20
21
  status TEXT NOT NULL,
21
22
  last_inbound_at TEXT,
@@ -53,6 +54,8 @@ export function createSqliteDatabase(filePath) {
53
54
  );
54
55
  `);
55
56
  ensureColumn(db, "bridge_sessions", "skill_context_key", "TEXT");
57
+ ensureColumn(db, "bridge_sessions", "last_codex_turn_id", "TEXT");
58
+ ensureColumn(db, "bridge_sessions", "conversation_provider", "TEXT");
56
59
  return db;
57
60
  }
58
61
  function ensureColumn(db, tableName, columnName, columnDefinition) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-codex-bridge",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A bridge between QQ Official Bot and Codex Desktop, with media relay, STT, thread management, and incremental reply delivery.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/983033995/qq-codex-bridge",
@@ -21,7 +21,8 @@
21
21
  "typescript"
22
22
  ],
23
23
  "bin": {
24
- "qq-codex-bridge": "bin/qq-codex-bridge.js"
24
+ "qq-codex-bridge": "bin/qq-codex-bridge.js",
25
+ "qq-codex-weixin-gateway": "bin/qq-codex-weixin-gateway.js"
25
26
  },
26
27
  "files": [
27
28
  "bin",
@@ -37,7 +38,12 @@
37
38
  "scripts": {
38
39
  "build": "tsc -p tsconfig.json",
39
40
  "dev": "tsx --env-file=.env apps/bridge-daemon/src/dev.ts",
41
+ "gitnexus:detect-changes": "node scripts/gitnexus-detect-changes.mjs",
42
+ "weixin:login": "tsx --env-file=.env apps/weixin-gateway/src/dev.ts -- --weixin-login",
43
+ "weixin:login:force": "tsx --env-file=.env apps/weixin-gateway/src/dev.ts -- --weixin-login-force",
44
+ "weixin:logout": "tsx --env-file=.env apps/weixin-gateway/src/dev.ts -- --weixin-logout",
40
45
  "start": "node dist/apps/bridge-daemon/src/cli.js",
46
+ "start:weixin-gateway": "node dist/apps/weixin-gateway/src/cli.js",
41
47
  "debug:codex-workers": "tsx apps/bridge-daemon/src/debug-codex-workers.ts",
42
48
  "test": "vitest run",
43
49
  "test:watch": "vitest",