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
@@ -0,0 +1,216 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { z } from "zod";
4
+ import { WeixinGatewayMessageStore } from "./message-store.js";
5
+ import { MediaArtifactKind } from "../../../packages/domain/src/message.js";
6
+ const inboundTextPayloadSchema = z.object({
7
+ accountKey: z.string().min(1).optional(),
8
+ chatType: z.enum(["c2c", "group"]).optional(),
9
+ senderId: z.string().min(1),
10
+ peerId: z.string().min(1).optional(),
11
+ messageId: z.string().min(1),
12
+ text: z.string().min(1),
13
+ receivedAt: z.string().optional()
14
+ });
15
+ const mediaArtifactSchema = z.object({
16
+ kind: z.nativeEnum(MediaArtifactKind),
17
+ sourceUrl: z.string().min(1),
18
+ localPath: z.string().min(1),
19
+ mimeType: z.string().min(1),
20
+ fileSize: z.number().nonnegative(),
21
+ originalName: z.string().min(1),
22
+ transcript: z.string().nullable().optional(),
23
+ transcriptSource: z.enum(["stt", "asr", "fallback"]).nullable().optional(),
24
+ extractedText: z.string().nullable().optional()
25
+ });
26
+ const outboundMessagePayloadSchema = z.object({
27
+ accountKey: z.string().min(1).optional(),
28
+ accountId: z.string().min(1).optional(),
29
+ peerId: z.string().min(1),
30
+ chatType: z.enum(["c2c", "group"]),
31
+ content: z.string().min(1).optional(),
32
+ mediaArtifacts: z.array(mediaArtifactSchema).min(1).optional(),
33
+ replyToMessageId: z.string().min(1).optional()
34
+ }).superRefine((payload, ctx) => {
35
+ if (!payload.content && !(payload.mediaArtifacts?.length)) {
36
+ ctx.addIssue({
37
+ code: z.ZodIssueCode.custom,
38
+ message: "content or mediaArtifacts is required",
39
+ path: ["content"]
40
+ });
41
+ }
42
+ });
43
+ export function createWeixinGatewayServer(deps) {
44
+ const messageStore = deps.messageStore
45
+ ?? new WeixinGatewayMessageStore(deps.config.messageStorePath, deps.config.recentMessageLimit);
46
+ const fetchFn = deps.fetchFn ?? fetch;
47
+ return createServer(async (request, response) => {
48
+ try {
49
+ if (request.method === "GET" && request.url === "/health") {
50
+ writeJson(response, 200, { ok: true });
51
+ return;
52
+ }
53
+ if (request.method === "GET" && request.url === "/messages") {
54
+ writeJson(response, 200, {
55
+ items: messageStore.listRecent()
56
+ });
57
+ return;
58
+ }
59
+ if (request.method !== "POST") {
60
+ writePlain(response, 405, "method not allowed");
61
+ return;
62
+ }
63
+ if (request.url === "/inbound/text") {
64
+ const payload = inboundTextPayloadSchema.parse(await readJsonBody(request));
65
+ const bridgePayload = {
66
+ accountKey: payload.accountKey,
67
+ chatType: payload.chatType ?? "c2c",
68
+ senderId: payload.senderId,
69
+ peerId: payload.peerId ?? payload.senderId,
70
+ messageId: payload.messageId,
71
+ text: payload.text,
72
+ receivedAt: payload.receivedAt
73
+ };
74
+ const bridgeResponse = await fetchFn(`${deps.config.bridgeBaseUrl}${deps.config.bridgeWebhookPath}`, {
75
+ method: "POST",
76
+ headers: {
77
+ "content-type": "application/json"
78
+ },
79
+ body: JSON.stringify(bridgePayload)
80
+ });
81
+ if (!bridgeResponse.ok) {
82
+ const bridgeText = await bridgeResponse.text().catch(() => "");
83
+ throw new Error(`bridge webhook failed: ${bridgeResponse.status}${bridgeText ? ` ${bridgeText}` : ""}`);
84
+ }
85
+ writeJson(response, 202, {
86
+ accepted: true
87
+ });
88
+ return;
89
+ }
90
+ if (request.url === "/messages") {
91
+ assertBearerToken(request, deps.config.expectedBearerToken);
92
+ const payload = outboundMessagePayloadSchema.parse(await readJsonBody(request));
93
+ const duplicate = findRecentDuplicateOutbound(messageStore.listRecent(), payload);
94
+ if (duplicate) {
95
+ writeJson(response, 200, { id: duplicate.id, deduped: true });
96
+ return;
97
+ }
98
+ const message = {
99
+ id: randomUUID(),
100
+ ...(payload.accountKey ? { accountKey: payload.accountKey } : {}),
101
+ ...(payload.accountId ? { accountId: payload.accountId } : {}),
102
+ peerId: payload.peerId,
103
+ chatType: payload.chatType,
104
+ ...(payload.content ? { content: payload.content } : {}),
105
+ ...(payload.mediaArtifacts?.length ? { mediaArtifacts: payload.mediaArtifacts } : {}),
106
+ ...(payload.replyToMessageId ? { replyToMessageId: payload.replyToMessageId } : {}),
107
+ createdAt: new Date().toISOString()
108
+ };
109
+ messageStore.append(message);
110
+ if (deps.outboundSender) {
111
+ if (payload.mediaArtifacts?.length && deps.outboundSender.sendMessage) {
112
+ await deps.outboundSender.sendMessage({
113
+ ...(payload.accountKey ? { accountKey: payload.accountKey } : {}),
114
+ ...(payload.accountId ? { accountId: payload.accountId } : {}),
115
+ peerId: payload.peerId,
116
+ chatType: payload.chatType,
117
+ ...(payload.content ? { content: payload.content } : {}),
118
+ mediaArtifacts: payload.mediaArtifacts,
119
+ ...(payload.replyToMessageId ? { replyToMessageId: payload.replyToMessageId } : {})
120
+ });
121
+ }
122
+ else {
123
+ await deps.outboundSender.sendTextMessage({
124
+ ...(payload.accountKey ? { accountKey: payload.accountKey } : {}),
125
+ ...(payload.accountId ? { accountId: payload.accountId } : {}),
126
+ peerId: payload.peerId,
127
+ chatType: payload.chatType,
128
+ text: payload.content ?? "",
129
+ ...(payload.replyToMessageId ? { replyToMessageId: payload.replyToMessageId } : {})
130
+ });
131
+ }
132
+ }
133
+ console.log("[weixin-gateway] outbound message", {
134
+ id: message.id,
135
+ chatType: message.chatType,
136
+ peerId: message.peerId,
137
+ preview: clipPreview(message.content ?? ""),
138
+ mediaCount: message.mediaArtifacts?.length ?? 0
139
+ });
140
+ writeJson(response, 200, { id: message.id });
141
+ return;
142
+ }
143
+ writePlain(response, 404, "not found");
144
+ }
145
+ catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ const statusCode = error instanceof z.ZodError ? 400 : 500;
148
+ writeJson(response, statusCode, {
149
+ error: message
150
+ });
151
+ }
152
+ });
153
+ }
154
+ async function readJsonBody(request) {
155
+ const chunks = [];
156
+ for await (const chunk of request) {
157
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
158
+ }
159
+ const raw = Buffer.concat(chunks).toString("utf8");
160
+ return raw ? JSON.parse(raw) : {};
161
+ }
162
+ function assertBearerToken(request, expectedToken) {
163
+ if (!expectedToken) {
164
+ return;
165
+ }
166
+ const authHeader = request.headers.authorization;
167
+ if (authHeader !== `Bearer ${expectedToken}`) {
168
+ throw new Error("unauthorized");
169
+ }
170
+ }
171
+ function clipPreview(text) {
172
+ const normalized = text.replace(/\s+/g, " ").trim();
173
+ return normalized.length > 80 ? `${normalized.slice(0, 77)}...` : normalized;
174
+ }
175
+ function findRecentDuplicateOutbound(recentMessages, payload) {
176
+ const fingerprint = buildOutboundFingerprint(payload);
177
+ if (!fingerprint) {
178
+ return null;
179
+ }
180
+ const now = Date.now();
181
+ for (const message of recentMessages) {
182
+ const createdAtMs = Date.parse(message.createdAt);
183
+ if (!Number.isFinite(createdAtMs) || now - createdAtMs > 120_000) {
184
+ continue;
185
+ }
186
+ if (buildOutboundFingerprint(message) === fingerprint) {
187
+ return message;
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+ function buildOutboundFingerprint(message) {
193
+ const peerId = String(message.peerId ?? "").trim();
194
+ const chatType = String(message.chatType ?? "").trim();
195
+ const replyToMessageId = String(message.replyToMessageId ?? "").trim();
196
+ const content = String(message.content ?? "").replace(/\s+/g, " ").trim();
197
+ const mediaFingerprint = (message.mediaArtifacts ?? [])
198
+ .map((artifact) => [
199
+ artifact.kind,
200
+ artifact.localPath || "",
201
+ artifact.sourceUrl || "",
202
+ artifact.originalName || ""
203
+ ].join("::"))
204
+ .join("|");
205
+ return [peerId, chatType, replyToMessageId, content, mediaFingerprint].join("||");
206
+ }
207
+ function writeJson(response, statusCode, payload) {
208
+ response.statusCode = statusCode;
209
+ response.setHeader("content-type", "application/json; charset=utf-8");
210
+ response.end(JSON.stringify(payload));
211
+ }
212
+ function writePlain(response, statusCode, payload) {
213
+ response.statusCode = statusCode;
214
+ response.setHeader("content-type", "text/plain; charset=utf-8");
215
+ response.end(payload);
216
+ }
@@ -0,0 +1,163 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const EMPTY_STATE = {
4
+ syncCursor: "",
5
+ contextTokens: {},
6
+ accounts: {},
7
+ defaultAccountId: "default"
8
+ };
9
+ export class WeixinGatewayStateStore {
10
+ filePath;
11
+ state = structuredClone(EMPTY_STATE);
12
+ constructor(filePath) {
13
+ this.filePath = filePath;
14
+ this.reload();
15
+ }
16
+ reload() {
17
+ this.state = loadStateFromDisk(this.filePath);
18
+ }
19
+ getSyncCursor() {
20
+ return this.state.syncCursor;
21
+ }
22
+ setSyncCursor(cursor) {
23
+ this.state.syncCursor = sanitizeText(cursor);
24
+ this.persist();
25
+ }
26
+ getContextToken(accountId, peerId) {
27
+ return this.state.contextTokens[buildContextTokenKey(accountId, peerId)] ?? "";
28
+ }
29
+ setContextToken(accountId, peerId, token) {
30
+ const key = buildContextTokenKey(accountId, peerId);
31
+ const normalizedToken = sanitizeText(token);
32
+ if (!key || !normalizedToken) {
33
+ return;
34
+ }
35
+ this.state.contextTokens[key] = normalizedToken;
36
+ this.persist();
37
+ }
38
+ getStoredAccount(accountId) {
39
+ const normalizedAccountId = sanitizeText(accountId) || this.state.defaultAccountId;
40
+ return this.state.accounts[normalizedAccountId] ?? null;
41
+ }
42
+ setStoredAccount(account) {
43
+ const normalizedAccountId = sanitizeText(account.accountId) || "default";
44
+ this.state.accounts[normalizedAccountId] = {
45
+ accountId: normalizedAccountId,
46
+ token: sanitizeText(account.token),
47
+ baseUrl: sanitizeText(account.baseUrl),
48
+ ...(sanitizeText(account.userId) ? { userId: sanitizeText(account.userId) } : {})
49
+ };
50
+ this.state.defaultAccountId = normalizedAccountId;
51
+ this.persist();
52
+ }
53
+ clearStoredAccount(accountId) {
54
+ const normalizedAccountId = sanitizeText(accountId) || this.state.defaultAccountId;
55
+ delete this.state.accounts[normalizedAccountId];
56
+ for (const key of Object.keys(this.state.contextTokens)) {
57
+ if (key.startsWith(`${normalizedAccountId}:`)) {
58
+ delete this.state.contextTokens[key];
59
+ }
60
+ }
61
+ if (this.state.defaultAccountId === normalizedAccountId) {
62
+ this.state.defaultAccountId = Object.keys(this.state.accounts)[0] ?? "default";
63
+ }
64
+ this.persist();
65
+ }
66
+ resolveRuntimeAccount(preferredAccountId, envOverride) {
67
+ const normalizedPreferred = sanitizeText(preferredAccountId) || this.state.defaultAccountId;
68
+ const stored = this.state.accounts[normalizedPreferred]
69
+ ?? this.state.accounts[this.state.defaultAccountId]
70
+ ?? null;
71
+ const token = sanitizeText(envOverride.token) || sanitizeText(stored?.token);
72
+ const baseUrl = sanitizeText(envOverride.baseUrl) || sanitizeText(stored?.baseUrl);
73
+ if (!token || !baseUrl) {
74
+ return null;
75
+ }
76
+ return {
77
+ accountId: sanitizeText(stored?.accountId) || normalizedPreferred || "default",
78
+ token,
79
+ baseUrl,
80
+ ...(sanitizeText(stored?.userId) ? { userId: sanitizeText(stored?.userId) } : {})
81
+ };
82
+ }
83
+ persist() {
84
+ persistStateToDisk(this.filePath, this.state);
85
+ }
86
+ }
87
+ function loadStateFromDisk(filePath) {
88
+ let raw = "";
89
+ try {
90
+ raw = fs.readFileSync(filePath, "utf8");
91
+ }
92
+ catch (error) {
93
+ if (error?.code === "ENOENT") {
94
+ return structuredClone(EMPTY_STATE);
95
+ }
96
+ throw error;
97
+ }
98
+ try {
99
+ const parsed = JSON.parse(raw);
100
+ return {
101
+ syncCursor: sanitizeText(parsed.syncCursor),
102
+ contextTokens: normalizeContextTokens(parsed.contextTokens),
103
+ accounts: normalizeAccounts(parsed.accounts),
104
+ defaultAccountId: sanitizeText(parsed.defaultAccountId) || "default"
105
+ };
106
+ }
107
+ catch {
108
+ return structuredClone(EMPTY_STATE);
109
+ }
110
+ }
111
+ function persistStateToDisk(filePath, state) {
112
+ const tempFile = `${filePath}.tmp`;
113
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
114
+ fs.writeFileSync(tempFile, JSON.stringify(state, null, 2), "utf8");
115
+ fs.renameSync(tempFile, filePath);
116
+ }
117
+ function normalizeContextTokens(input) {
118
+ const result = {};
119
+ if (!input || typeof input !== "object") {
120
+ return result;
121
+ }
122
+ for (const [key, value] of Object.entries(input)) {
123
+ const normalizedKey = sanitizeText(key);
124
+ const normalizedValue = sanitizeText(value);
125
+ if (!normalizedKey || !normalizedValue) {
126
+ continue;
127
+ }
128
+ result[normalizedKey] = normalizedValue;
129
+ }
130
+ return result;
131
+ }
132
+ function normalizeAccounts(input) {
133
+ const result = {};
134
+ if (!input || typeof input !== "object") {
135
+ return result;
136
+ }
137
+ for (const [key, value] of Object.entries(input)) {
138
+ const accountId = sanitizeText(key);
139
+ const token = sanitizeText(value?.token);
140
+ const baseUrl = sanitizeText(value?.baseUrl);
141
+ if (!accountId || !token || !baseUrl) {
142
+ continue;
143
+ }
144
+ result[accountId] = {
145
+ accountId,
146
+ token,
147
+ baseUrl,
148
+ ...(sanitizeText(value?.userId) ? { userId: sanitizeText(value?.userId) } : {})
149
+ };
150
+ }
151
+ return result;
152
+ }
153
+ function buildContextTokenKey(accountId, peerId) {
154
+ const normalizedAccountId = sanitizeText(accountId);
155
+ const normalizedPeerId = sanitizeText(peerId);
156
+ if (!normalizedAccountId || !normalizedPeerId) {
157
+ return "";
158
+ }
159
+ return `${normalizedAccountId}:${normalizedPeerId}`;
160
+ }
161
+ function sanitizeText(value) {
162
+ return String(value ?? "").trim();
163
+ }