qq-codex-bridge 0.1.2 → 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 +320 -23
  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
@@ -0,0 +1,161 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { join } from "node:path";
3
+ import { checkAccessibility, clickChatByTitle, clickNewChat, ensureAppVisible, getCurrentWindowTitle, healthCheck, listRecentChats, sendMessage } from "./ax-client.js";
4
+ import { diffCache, isCacheDirReachable, saveToDest, snapshotCache, waitForCacheImages } from "./image-cache.js";
5
+ import { ChatgptSessionRegistry } from "./session-registry.js";
6
+ const DEFAULT_DEST_DIR = join(process.cwd(), "runtime", "media", "chatgpt");
7
+ const DEFAULT_TEXT_TIMEOUT_MS = 60_000;
8
+ const DEFAULT_IMAGE_TIMEOUT_MS = 180_000;
9
+ export class ChatgptDesktopDriver {
10
+ registry;
11
+ destDir;
12
+ constructor(opts = {}) {
13
+ this.registry = new ChatgptSessionRegistry(opts.registryPath);
14
+ this.destDir = opts.destDir ?? DEFAULT_DEST_DIR;
15
+ }
16
+ async health() {
17
+ const { appRunning, accessibility, frontmost } = healthCheck();
18
+ const cacheDirFound = isCacheDirReachable();
19
+ return {
20
+ ok: appRunning && accessibility && cacheDirFound,
21
+ appRunning,
22
+ accessibility,
23
+ cacheDirFound,
24
+ frontmost
25
+ };
26
+ }
27
+ listChats(maxCount = 20) {
28
+ return listRecentChats(maxCount);
29
+ }
30
+ switchToChat(title) {
31
+ return clickChatByTitle(title);
32
+ }
33
+ markSwitched(sessionKey, threadTitle) {
34
+ // signal that user manually switched to a thread — next run() should skip clickNewChat
35
+ this.registry.set(sessionKey, threadTitle ?? "__switched__", null);
36
+ }
37
+ newChat(sessionKey) {
38
+ if (sessionKey) {
39
+ this.registry.delete(sessionKey);
40
+ }
41
+ clickNewChat();
42
+ }
43
+ getSessionThreadRef(sessionKey) {
44
+ return this.registry.get(sessionKey)?.threadRef ?? null;
45
+ }
46
+ getCurrentThreadTitle() {
47
+ return getCurrentWindowTitle();
48
+ }
49
+ async run(input) {
50
+ const t0 = Date.now();
51
+ const turnId = randomUUID();
52
+ if (!checkAccessibility()) {
53
+ return {
54
+ ok: false,
55
+ provider: "chatgpt-desktop",
56
+ errorCode: "accessibility_denied",
57
+ message: "Accessibility permission not granted. Enable in System Settings > Privacy > Accessibility."
58
+ };
59
+ }
60
+ try {
61
+ ensureAppVisible();
62
+ }
63
+ catch (err) {
64
+ return {
65
+ ok: false,
66
+ provider: "chatgpt-desktop",
67
+ errorCode: "app_not_ready",
68
+ message: err instanceof Error ? err.message : String(err)
69
+ };
70
+ }
71
+ // decide whether to open a new thread
72
+ const existing = input.sessionKey ? this.registry.get(input.sessionKey) : null;
73
+ // skip clickNewChat if: registry has an entry (continuing thread) OR user manually switched ("__switched__" sentinel)
74
+ const needNewThread = !existing;
75
+ if (needNewThread) {
76
+ try {
77
+ clickNewChat();
78
+ }
79
+ catch {
80
+ // non-fatal: may already be on a fresh thread
81
+ }
82
+ }
83
+ const beforeCache = await snapshotCache();
84
+ const timeoutMs = input.timeoutMs ?? (input.mode === "image" ? DEFAULT_IMAGE_TIMEOUT_MS : DEFAULT_TEXT_TIMEOUT_MS);
85
+ const { confirmed, completed, elapsedMs, replyTexts } = sendMessage(input.prompt, {
86
+ attachmentPaths: input.attachmentPaths,
87
+ confirmTimeoutMs: input.attachmentPaths?.length ? 20_000 : 8_000,
88
+ completionTimeoutMs: timeoutMs
89
+ });
90
+ if (!confirmed) {
91
+ return {
92
+ ok: false,
93
+ provider: "chatgpt-desktop",
94
+ errorCode: "send_failed",
95
+ message: "Message was sent but 'Stop generating' button never appeared — ChatGPT may not have accepted the input."
96
+ };
97
+ }
98
+ if (!completed) {
99
+ return {
100
+ ok: false,
101
+ provider: "chatgpt-desktop",
102
+ errorCode: "reply_timeout",
103
+ message: `Timed out waiting for ChatGPT Desktop reply after ${timeoutMs}ms`
104
+ };
105
+ }
106
+ // collect window title for session registry; use as threadRef so we can navigate back
107
+ const windowTitle = getCurrentWindowTitle();
108
+ if (input.sessionKey) {
109
+ this.registry.set(input.sessionKey, windowTitle ?? existing?.threadRef ?? null, windowTitle);
110
+ }
111
+ // collect text reply (replyTexts already diffed inside Swift process)
112
+ const replyText = replyTexts
113
+ .filter((t) => t.length > 0)
114
+ .join("\n")
115
+ .trim();
116
+ // collect images (for image mode, or if images appeared in text mode)
117
+ const media = [];
118
+ const cacheImages = input.mode === "image"
119
+ ? await waitForCacheImages(beforeCache, { timeoutMs: 45_000, intervalMs: 1_000 })
120
+ : await diffCache(beforeCache);
121
+ const newCacheFiles = input.mode === "image" ? cacheImages.slice(-1) : cacheImages;
122
+ if (newCacheFiles.length > 0) {
123
+ const prefix = `chatgpt-${turnId.slice(0, 8)}`;
124
+ const saved = await saveToDest(newCacheFiles, this.destDir, prefix);
125
+ for (const s of saved) {
126
+ media.push({
127
+ kind: "image",
128
+ localPath: s.localPath,
129
+ mimeType: s.mimeType,
130
+ fileSize: s.fileSize,
131
+ originalName: s.originalName
132
+ });
133
+ }
134
+ }
135
+ if (input.mode === "image" && media.length === 0) {
136
+ return {
137
+ ok: false,
138
+ provider: "chatgpt-desktop",
139
+ errorCode: "image_not_found",
140
+ message: "No new image found in Kingfisher cache after generation completed."
141
+ };
142
+ }
143
+ if (!replyText && media.length === 0) {
144
+ return {
145
+ ok: false,
146
+ provider: "chatgpt-desktop",
147
+ errorCode: "reply_parse_failed",
148
+ message: "Reply completed but no text or images were collected from AX tree."
149
+ };
150
+ }
151
+ return {
152
+ ok: true,
153
+ provider: "chatgpt-desktop",
154
+ threadRef: existing?.threadRef ?? null,
155
+ turnId,
156
+ text: replyText,
157
+ media,
158
+ elapsedMs: Date.now() - t0
159
+ };
160
+ }
161
+ }
@@ -0,0 +1,155 @@
1
+ import { createReadStream, readdirSync } from "node:fs";
2
+ import { copyFile, mkdir, readdir, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join, relative } from "node:path";
5
+ const DEFAULT_CACHE_DIR = join(homedir(), "Library/Caches/com.openai.chat", "com.onevcat.Kingfisher.ImageCache", "com.onevcat.Kingfisher.ImageCache.com.openai.chat");
6
+ const MIN_IMAGE_BYTES = 50_000;
7
+ function cacheDir() {
8
+ return process.env.CHATGPT_DESKTOP_CACHE_DIR ?? DEFAULT_CACHE_DIR;
9
+ }
10
+ async function listCacheFiles(dir = cacheDir()) {
11
+ const entries = await readdir(dir, { withFileTypes: true });
12
+ const files = [];
13
+ for (const entry of entries) {
14
+ const fullPath = join(dir, entry.name);
15
+ if (entry.isDirectory()) {
16
+ const nested = await listCacheFiles(fullPath);
17
+ files.push(...nested.map((file) => ({
18
+ name: relative(dir, file.path),
19
+ path: file.path
20
+ })));
21
+ continue;
22
+ }
23
+ if (entry.isFile()) {
24
+ files.push({
25
+ name: relative(dir, fullPath),
26
+ path: fullPath
27
+ });
28
+ }
29
+ }
30
+ return files;
31
+ }
32
+ export async function snapshotCache() {
33
+ const timestamp = Date.now();
34
+ try {
35
+ const files = await listCacheFiles();
36
+ return { files: new Set(files.map((file) => file.name)), timestamp };
37
+ }
38
+ catch {
39
+ return { files: new Set(), timestamp };
40
+ }
41
+ }
42
+ async function readMagicBytes(filePath, n) {
43
+ return new Promise((resolve, reject) => {
44
+ const buf = [];
45
+ let collected = 0;
46
+ const stream = createReadStream(filePath, { start: 0, end: n - 1 });
47
+ stream.on("data", (chunk) => {
48
+ const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
49
+ buf.push(b);
50
+ collected += b.length;
51
+ });
52
+ stream.on("end", () => resolve(Buffer.concat(buf, collected)));
53
+ stream.on("error", reject);
54
+ });
55
+ }
56
+ function detectMime(magic) {
57
+ if (magic.length < 4)
58
+ return null;
59
+ if (magic[0] === 0x89 && magic[1] === 0x50 && magic[2] === 0x4e && magic[3] === 0x47)
60
+ return "image/png";
61
+ if (magic[0] === 0xff && magic[1] === 0xd8 && magic[2] === 0xff)
62
+ return "image/jpeg";
63
+ if (magic[0] === 0x52 && magic[1] === 0x49 && magic[2] === 0x46 && magic[3] === 0x46 &&
64
+ magic.length >= 12 &&
65
+ magic[8] === 0x57 && magic[9] === 0x45 && magic[10] === 0x42 && magic[11] === 0x50)
66
+ return "image/webp";
67
+ if (magic[0] === 0x47 && magic[1] === 0x49 && magic[2] === 0x46)
68
+ return "image/gif";
69
+ return null;
70
+ }
71
+ export async function diffCache(before) {
72
+ let current;
73
+ try {
74
+ current = await listCacheFiles();
75
+ }
76
+ catch {
77
+ return [];
78
+ }
79
+ const images = [];
80
+ for (const { name, path: fullPath } of current) {
81
+ if (before.files.has(name)) {
82
+ continue;
83
+ }
84
+ try {
85
+ const info = await stat(fullPath);
86
+ if (!info.isFile() || info.size < MIN_IMAGE_BYTES)
87
+ continue;
88
+ const magic = await readMagicBytes(fullPath, 16);
89
+ const mimeType = detectMime(magic);
90
+ if (!mimeType)
91
+ continue;
92
+ images.push({
93
+ sourcePath: fullPath,
94
+ fileName: name,
95
+ mimeType,
96
+ fileSize: info.size,
97
+ createdAtMs: Math.max(info.birthtimeMs, info.ctimeMs)
98
+ });
99
+ }
100
+ catch {
101
+ // skip unreadable files
102
+ }
103
+ }
104
+ return images.sort((a, b) => a.createdAtMs - b.createdAtMs);
105
+ }
106
+ function delay(ms) {
107
+ return new Promise((resolve) => {
108
+ setTimeout(resolve, ms);
109
+ });
110
+ }
111
+ export async function waitForCacheImages(before, options = {}) {
112
+ const timeoutMs = options.timeoutMs ?? 30_000;
113
+ const intervalMs = options.intervalMs ?? 1_000;
114
+ const deadline = Date.now() + timeoutMs;
115
+ while (true) {
116
+ const images = await diffCache(before);
117
+ if (images.length > 0 || Date.now() >= deadline) {
118
+ return images;
119
+ }
120
+ await delay(intervalMs);
121
+ }
122
+ }
123
+ const EXT = {
124
+ "image/png": ".png",
125
+ "image/jpeg": ".jpg",
126
+ "image/webp": ".webp",
127
+ "image/gif": ".gif"
128
+ };
129
+ export async function saveToDest(images, destDir, prefix) {
130
+ await mkdir(destDir, { recursive: true });
131
+ const saved = [];
132
+ for (let i = 0; i < images.length; i++) {
133
+ const img = images[i];
134
+ const ext = EXT[img.mimeType] ?? ".bin";
135
+ const name = `${prefix}-${String(i + 1).padStart(3, "0")}${ext}`;
136
+ const dest = join(destDir, name);
137
+ await copyFile(img.sourcePath, dest);
138
+ saved.push({
139
+ localPath: dest,
140
+ originalName: name,
141
+ mimeType: img.mimeType,
142
+ fileSize: img.fileSize
143
+ });
144
+ }
145
+ return saved;
146
+ }
147
+ export function isCacheDirReachable() {
148
+ try {
149
+ readdirSync(cacheDir());
150
+ return true;
151
+ }
152
+ catch {
153
+ return false;
154
+ }
155
+ }
@@ -0,0 +1,48 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const DEFAULT_REGISTRY_PATH = join(homedir(), ".qq-codex-bridge", "chatgpt-session-registry.json");
5
+ export class ChatgptSessionRegistry {
6
+ path;
7
+ data;
8
+ constructor(registryPath) {
9
+ this.path = registryPath ?? DEFAULT_REGISTRY_PATH;
10
+ this.data = this.load();
11
+ }
12
+ load() {
13
+ if (!existsSync(this.path)) {
14
+ return { version: 1, entries: {} };
15
+ }
16
+ try {
17
+ const raw = readFileSync(this.path, "utf-8");
18
+ return JSON.parse(raw);
19
+ }
20
+ catch {
21
+ return { version: 1, entries: {} };
22
+ }
23
+ }
24
+ save() {
25
+ const dir = join(this.path, "..");
26
+ mkdirSync(dir, { recursive: true });
27
+ writeFileSync(this.path, JSON.stringify(this.data, null, 2), "utf-8");
28
+ }
29
+ get(sessionKey) {
30
+ return this.data.entries[sessionKey] ?? null;
31
+ }
32
+ set(sessionKey, threadRef, windowTitle) {
33
+ this.data.entries[sessionKey] = {
34
+ sessionKey,
35
+ threadRef,
36
+ windowTitle: windowTitle ?? null,
37
+ updatedAt: new Date().toISOString()
38
+ };
39
+ this.save();
40
+ }
41
+ delete(sessionKey) {
42
+ delete this.data.entries[sessionKey];
43
+ this.save();
44
+ }
45
+ all() {
46
+ return Object.values(this.data.entries);
47
+ }
48
+ }