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,36 @@
1
+ import { createServer } from "node:http";
2
+ export function createQqWebhookServer(deps) {
3
+ return createServer(async (request, response) => {
4
+ if (request.url !== deps.webhookPath) {
5
+ response.statusCode = 404;
6
+ response.end("not found");
7
+ return;
8
+ }
9
+ if (request.method !== "POST") {
10
+ response.statusCode = 405;
11
+ response.end("method not allowed");
12
+ return;
13
+ }
14
+ const chunks = [];
15
+ for await (const chunk of request) {
16
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
17
+ }
18
+ let payload;
19
+ try {
20
+ payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
21
+ }
22
+ catch (error) {
23
+ response.statusCode = 400;
24
+ response.end(error instanceof Error ? error.message : "invalid request");
25
+ return;
26
+ }
27
+ Promise.resolve()
28
+ .then(() => deps.ingress.dispatchPayload(payload))
29
+ .catch((error) => {
30
+ const normalized = error instanceof Error ? error : new Error(typeof error === "string" ? error : "dispatch failed");
31
+ deps.onDispatchError?.(normalized, payload);
32
+ });
33
+ response.statusCode = 202;
34
+ response.end("accepted");
35
+ });
36
+ }
@@ -0,0 +1,57 @@
1
+ import { pathToFileURL } from "node:url";
2
+ import { bootstrap } from "./bootstrap.js";
3
+ import { ThreadCommandHandler } from "./thread-command-handler.js";
4
+ export function createIngressMessageHandler(deps) {
5
+ return async (message) => {
6
+ try {
7
+ const handled = await deps.threadCommandHandler.handleIfCommand(message);
8
+ if (handled) {
9
+ return;
10
+ }
11
+ await deps.orchestrator.handleInbound(message);
12
+ }
13
+ catch (error) {
14
+ console.error("[qq-codex-bridge] message handling failed", {
15
+ messageId: message.messageId,
16
+ sessionKey: message.sessionKey,
17
+ error: error instanceof Error ? error.message : String(error)
18
+ });
19
+ if (error instanceof Error && error.stack) {
20
+ console.error(" stack:", error.stack);
21
+ }
22
+ }
23
+ };
24
+ }
25
+ export async function runBridgeDaemon() {
26
+ const app = bootstrap();
27
+ const threadCommandHandler = new ThreadCommandHandler({
28
+ sessionStore: app.sessionStore,
29
+ transcriptStore: app.transcriptStore,
30
+ desktopDriver: app.adapters.codexDesktop,
31
+ qqEgress: app.adapters.qq.egress
32
+ });
33
+ await app.adapters.qq.ingress.onMessage(createIngressMessageHandler({
34
+ threadCommandHandler,
35
+ orchestrator: app.orchestrator
36
+ }));
37
+ await app.adapters.qq.ingress.start();
38
+ console.log("[qq-codex-bridge] ready", {
39
+ transport: "qq-gateway-websocket",
40
+ accountKey: "qqbot:default"
41
+ });
42
+ }
43
+ function handleFatal(error) {
44
+ const cause = error instanceof Error ? error.cause : undefined;
45
+ console.error("[qq-codex-bridge] fatal:", error instanceof Error ? error.message : String(error));
46
+ if (cause !== undefined) {
47
+ console.error(" caused by:", cause);
48
+ }
49
+ if (error instanceof Error && error.stack) {
50
+ console.error(" stack:", error.stack);
51
+ }
52
+ process.exitCode = 1;
53
+ }
54
+ const entrypoint = process.argv[1];
55
+ if (entrypoint && import.meta.url === pathToFileURL(entrypoint).href) {
56
+ runBridgeDaemon().catch(handleFatal);
57
+ }
@@ -0,0 +1,197 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { BridgeSessionStatus } from "../../../packages/domain/src/session.js";
3
+ import { DesktopDriverError } from "../../../packages/domain/src/driver.js";
4
+ export class ThreadCommandHandler {
5
+ deps;
6
+ constructor(deps) {
7
+ this.deps = deps;
8
+ }
9
+ async handleIfCommand(message) {
10
+ if (message.chatType !== "c2c") {
11
+ return false;
12
+ }
13
+ const text = message.text.trim();
14
+ if (!this.isSupportedCommand(text)) {
15
+ return false;
16
+ }
17
+ const alreadySeen = await this.deps.transcriptStore.hasInbound(message.messageId);
18
+ if (alreadySeen) {
19
+ return true;
20
+ }
21
+ await this.deps.sessionStore.withSessionLock(message.sessionKey, async () => {
22
+ const seenInsideLock = await this.deps.transcriptStore.hasInbound(message.messageId);
23
+ if (seenInsideLock) {
24
+ return;
25
+ }
26
+ await this.ensureSessionExists(message);
27
+ await this.deps.transcriptStore.recordInbound(message);
28
+ if (text === "/threads" || text === "/t") {
29
+ const threads = await this.deps.desktopDriver.listRecentThreads(20);
30
+ await this.deliverControlReply(message, this.formatThreads(threads));
31
+ return;
32
+ }
33
+ if (text === "/thread current" || text === "/tc") {
34
+ const session = await this.deps.sessionStore.getSession(message.sessionKey);
35
+ const threads = await this.deps.desktopDriver.listRecentThreads(20);
36
+ const current = threads.find((thread) => thread.threadRef === session?.codexThreadRef)
37
+ ?? threads.find((thread) => thread.isCurrent)
38
+ ?? null;
39
+ const reply = current
40
+ ? `当前绑定线程:${current.title}${current.projectName ? `\n项目:${current.projectName}` : ""}${current.relativeTime ? `\n最近活动:${current.relativeTime}` : ""}`
41
+ : "当前私聊还没有绑定线程。";
42
+ await this.deliverControlReply(message, reply);
43
+ return;
44
+ }
45
+ if (text === "/help") {
46
+ await this.deliverControlReply(message, this.buildHelpText());
47
+ return;
48
+ }
49
+ const useMatch = text.match(/^(?:\/thread\s+use|\/tu)\s+(\d+)$/);
50
+ if (useMatch) {
51
+ const index = Number(useMatch[1]);
52
+ const threads = await this.deps.desktopDriver.listRecentThreads(20);
53
+ const thread = threads[index - 1];
54
+ if (!thread) {
55
+ await this.deliverControlReply(message, `没有第 ${index} 个线程。请先发送 /threads 查看列表。`);
56
+ return;
57
+ }
58
+ let binding;
59
+ try {
60
+ binding = await this.deps.desktopDriver.switchToThread(message.sessionKey, thread.threadRef);
61
+ }
62
+ catch (error) {
63
+ if (error instanceof DesktopDriverError && error.reason === "session_not_found") {
64
+ await this.deliverControlReply(message, `切换失败:没有在当前 Codex 侧边栏里找到这个线程。\n请先发送 /t 刷新列表后重试。`);
65
+ return;
66
+ }
67
+ throw error;
68
+ }
69
+ await this.deps.sessionStore.updateBinding(message.sessionKey, binding.codexThreadRef);
70
+ await this.deliverControlReply(message, `已切换到线程:${thread.title}${thread.projectName ? `\n项目:${thread.projectName}` : ""}`);
71
+ return;
72
+ }
73
+ const newMatch = text.match(/^(?:\/thread\s+new|\/tn)\s+(.+)$/);
74
+ if (newMatch) {
75
+ const title = newMatch[1].trim();
76
+ const binding = await this.deps.desktopDriver.createThread(message.sessionKey, this.buildNewThreadSeedPrompt(title));
77
+ await this.deps.sessionStore.updateBinding(message.sessionKey, binding.codexThreadRef);
78
+ await this.deliverControlReply(message, `已创建并切换到新线程:${title}`);
79
+ return;
80
+ }
81
+ const forkMatch = text.match(/^(?:\/thread\s+fork|\/tf)\s+(.+)$/);
82
+ if (forkMatch) {
83
+ const title = forkMatch[1].trim();
84
+ const recentConversation = await this.deps.transcriptStore.listRecentConversation(message.sessionKey, 8);
85
+ const binding = await this.deps.desktopDriver.createThread(message.sessionKey, this.buildForkThreadSeedPrompt(title, recentConversation));
86
+ await this.deps.sessionStore.updateBinding(message.sessionKey, binding.codexThreadRef);
87
+ await this.deliverControlReply(message, `已根据最近几轮对话 fork 新线程:${title}`);
88
+ return;
89
+ }
90
+ await this.deliverControlReply(message, this.buildHelpText());
91
+ });
92
+ return true;
93
+ }
94
+ async ensureSessionExists(message) {
95
+ const existing = await this.deps.sessionStore.getSession(message.sessionKey);
96
+ if (existing) {
97
+ return;
98
+ }
99
+ const created = {
100
+ sessionKey: message.sessionKey,
101
+ accountKey: message.accountKey,
102
+ peerKey: message.peerKey,
103
+ chatType: message.chatType,
104
+ peerId: message.senderId,
105
+ codexThreadRef: null,
106
+ skillContextKey: null,
107
+ status: BridgeSessionStatus.Active,
108
+ lastInboundAt: message.receivedAt,
109
+ lastOutboundAt: null,
110
+ lastError: null
111
+ };
112
+ await this.deps.sessionStore.createSession(created);
113
+ }
114
+ isSupportedCommand(text) {
115
+ return (text === "/threads" ||
116
+ text === "/t" ||
117
+ text === "/thread current" ||
118
+ text === "/tc" ||
119
+ text === "/help" ||
120
+ /^\/thread\s+use\s+\d+$/.test(text) ||
121
+ /^\/tu\s+\d+$/.test(text) ||
122
+ /^\/thread\s+new\s+.+$/.test(text) ||
123
+ /^\/tn\s+.+$/.test(text) ||
124
+ /^\/thread\s+fork\s+.+$/.test(text) ||
125
+ /^\/tf\s+.+$/.test(text) ||
126
+ text === "/thread");
127
+ }
128
+ formatThreads(threads) {
129
+ if (threads.length === 0) {
130
+ return "当前没有可用的 Codex 线程。";
131
+ }
132
+ const escapeCell = (value) => (value ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ").trim();
133
+ return [
134
+ "最近 20 条最近有消息活动的 Codex 线程:",
135
+ "",
136
+ "| 序号 | 项目 | 线程标题 | 最近活动 |",
137
+ "| --- | --- | --- | --- |",
138
+ ...threads.map((thread) => {
139
+ const index = thread.isCurrent ? `→ ${thread.index}` : `${thread.index}`;
140
+ const project = escapeCell(thread.projectName) || "-";
141
+ const title = escapeCell(thread.title) || "-";
142
+ const time = escapeCell(thread.relativeTime) || "-";
143
+ return `| ${index} | ${project} | ${title} | ${time} |`;
144
+ })
145
+ ].join("\n");
146
+ }
147
+ async deliverControlReply(message, text) {
148
+ const draft = {
149
+ draftId: randomUUID(),
150
+ sessionKey: message.sessionKey,
151
+ text,
152
+ createdAt: new Date().toISOString(),
153
+ replyToMessageId: message.messageId
154
+ };
155
+ await this.deps.transcriptStore.recordOutbound(draft);
156
+ await this.deps.qqEgress.deliver(draft);
157
+ }
158
+ buildHelpText() {
159
+ return [
160
+ "线程管理命令:",
161
+ "",
162
+ "| 用途 | 完整命令 | 简写 |",
163
+ "| --- | --- | --- |",
164
+ "| 查看最近活跃线程 | `/threads` | `/t` |",
165
+ "| 查看当前绑定线程 | `/thread current` | `/tc` |",
166
+ "| 切换到指定线程 | `/thread use <序号>` | `/tu <序号>` |",
167
+ "| 新建线程 | `/thread new <标题>` | `/tn <标题>` |",
168
+ "| 基于最近对话 fork 线程 | `/thread fork <标题>` | `/tf <标题>` |",
169
+ "",
170
+ "建议先发 `/t` 看列表,再用 `/tu 2` 这种方式切换。"
171
+ ].join("\n");
172
+ }
173
+ buildNewThreadSeedPrompt(title) {
174
+ return [
175
+ `线程标题:${title}`,
176
+ "",
177
+ "这是一个刚创建的新线程。",
178
+ "请把上面的标题视为本线程主题。",
179
+ "现在无需展开分析,只需理解上下文并等待我的下一条消息。"
180
+ ].join("\n");
181
+ }
182
+ buildForkThreadSeedPrompt(title, entries) {
183
+ const summaryLines = entries.map((entry) => {
184
+ const speaker = entry.direction === "inbound" ? "用户" : "助手";
185
+ return `- ${speaker}:${entry.text}`;
186
+ });
187
+ return [
188
+ `线程标题:${title}`,
189
+ "",
190
+ "这是从另一个 QQ 私聊会话中 fork 出来的新线程。",
191
+ "以下是最近几轮 QQ 对话摘要,请把它们作为本线程的起始上下文:",
192
+ ...(summaryLines.length > 0 ? summaryLines : ["- 暂无可用对话摘要"]),
193
+ "",
194
+ "请理解上下文,等待我的下一条消息。"
195
+ ].join("\n");
196
+ }
197
+ }
@@ -0,0 +1,189 @@
1
+ import WebSocket from "ws";
2
+ export class CdpSession {
3
+ config;
4
+ fetchFn;
5
+ connection = null;
6
+ browserSocket = null;
7
+ browserSocketPromise = null;
8
+ nextCommandId = 1;
9
+ pendingCommands = new Map();
10
+ attachedSessions = new Map();
11
+ constructor(config, options = {}) {
12
+ this.config = config;
13
+ this.fetchFn = options.fetchFn ?? fetch;
14
+ }
15
+ async connect() {
16
+ if (this.connection) {
17
+ return this.connection;
18
+ }
19
+ const response = await this.fetchFn(`http://127.0.0.1:${this.config.remoteDebuggingPort}/json/version`);
20
+ if (!response.ok) {
21
+ throw new Error(`CDP connect failed: ${response.status}`);
22
+ }
23
+ const payload = (await response.json());
24
+ if (!payload.webSocketDebuggerUrl) {
25
+ throw new Error("CDP version response missing webSocketDebuggerUrl");
26
+ }
27
+ this.connection = {
28
+ appName: this.config.appName,
29
+ browserVersion: payload.Browser ?? "unknown",
30
+ browserWebSocketUrl: payload.webSocketDebuggerUrl
31
+ };
32
+ return this.connection;
33
+ }
34
+ getBrowserWebSocketUrl() {
35
+ return this.connection?.browserWebSocketUrl ?? null;
36
+ }
37
+ async listTargets() {
38
+ const response = await this.fetchFn(`http://127.0.0.1:${this.config.remoteDebuggingPort}/json/list`);
39
+ if (!response.ok) {
40
+ throw new Error(`CDP target listing failed: ${response.status}`);
41
+ }
42
+ const payload = (await response.json());
43
+ return payload.map((target) => ({
44
+ id: target.id ?? "",
45
+ title: target.title ?? "",
46
+ type: target.type ?? "unknown",
47
+ url: target.url ?? ""
48
+ }));
49
+ }
50
+ async evaluateOnPage(expression, targetId) {
51
+ const target = await this.resolvePageTarget(targetId);
52
+ const sessionId = await this.attachToTarget(target.id);
53
+ const payload = (await this.sendCommand("Runtime.evaluate", {
54
+ expression,
55
+ returnByValue: true,
56
+ awaitPromise: true
57
+ }, sessionId));
58
+ if (payload.exceptionDetails) {
59
+ const detail = payload.exceptionDetails.exception?.description ||
60
+ payload.exceptionDetails.text ||
61
+ "unknown runtime exception";
62
+ throw new Error(`CDP runtime evaluation failed: ${detail}`);
63
+ }
64
+ return payload.result?.value;
65
+ }
66
+ async insertText(text, targetId) {
67
+ const target = await this.resolvePageTarget(targetId);
68
+ const sessionId = await this.attachToTarget(target.id);
69
+ await this.sendCommand("Input.insertText", {
70
+ text
71
+ }, sessionId);
72
+ }
73
+ async dispatchKeyEvent(event, targetId) {
74
+ const target = await this.resolvePageTarget(targetId);
75
+ const sessionId = await this.attachToTarget(target.id);
76
+ await this.sendCommand("Input.dispatchKeyEvent", event, sessionId);
77
+ }
78
+ async resolvePageTarget(targetId) {
79
+ const targets = await this.listTargets();
80
+ if (targetId) {
81
+ const target = targets.find((candidate) => candidate.id === targetId);
82
+ if (!target) {
83
+ throw new Error(`CDP target not found: ${targetId}`);
84
+ }
85
+ return target;
86
+ }
87
+ const pageTarget = targets.find((candidate) => candidate.type === "page");
88
+ if (!pageTarget) {
89
+ throw new Error("CDP target listing did not include a page target");
90
+ }
91
+ return pageTarget;
92
+ }
93
+ async attachToTarget(targetId) {
94
+ const cachedSessionId = this.attachedSessions.get(targetId);
95
+ if (cachedSessionId) {
96
+ return cachedSessionId;
97
+ }
98
+ const payload = (await this.sendCommand("Target.attachToTarget", {
99
+ targetId,
100
+ flatten: true
101
+ }));
102
+ if (!payload.sessionId) {
103
+ throw new Error(`CDP attach failed for target ${targetId}`);
104
+ }
105
+ this.attachedSessions.set(targetId, payload.sessionId);
106
+ return payload.sessionId;
107
+ }
108
+ async sendCommand(method, params, sessionId) {
109
+ const socket = await this.getBrowserSocket();
110
+ const id = this.nextCommandId++;
111
+ return new Promise((resolve, reject) => {
112
+ this.pendingCommands.set(id, { resolve, reject });
113
+ const command = JSON.stringify({
114
+ id,
115
+ method,
116
+ params,
117
+ ...(sessionId ? { sessionId } : {})
118
+ });
119
+ socket.send(command, (error) => {
120
+ if (!error) {
121
+ return;
122
+ }
123
+ this.pendingCommands.delete(id);
124
+ reject(error);
125
+ });
126
+ });
127
+ }
128
+ async getBrowserSocket() {
129
+ if (this.browserSocket) {
130
+ return this.browserSocket;
131
+ }
132
+ if (this.browserSocketPromise) {
133
+ return this.browserSocketPromise;
134
+ }
135
+ this.browserSocketPromise = this.connectBrowserSocket();
136
+ return this.browserSocketPromise;
137
+ }
138
+ async connectBrowserSocket() {
139
+ const connection = await this.connect();
140
+ return new Promise((resolve, reject) => {
141
+ const socket = new WebSocket(connection.browserWebSocketUrl);
142
+ const cleanup = () => {
143
+ socket.off("open", handleOpen);
144
+ socket.off("error", handleError);
145
+ };
146
+ const handleOpen = () => {
147
+ cleanup();
148
+ this.browserSocket = socket;
149
+ this.browserSocketPromise = null;
150
+ socket.on("message", (payload) => {
151
+ this.handleSocketMessage(payload.toString());
152
+ });
153
+ socket.on("close", () => {
154
+ this.browserSocket = null;
155
+ this.attachedSessions.clear();
156
+ const pending = Array.from(this.pendingCommands.values());
157
+ this.pendingCommands.clear();
158
+ for (const command of pending) {
159
+ command.reject(new Error("CDP browser websocket closed"));
160
+ }
161
+ });
162
+ resolve(socket);
163
+ };
164
+ const handleError = (error) => {
165
+ cleanup();
166
+ this.browserSocketPromise = null;
167
+ reject(error);
168
+ };
169
+ socket.once("open", handleOpen);
170
+ socket.once("error", handleError);
171
+ });
172
+ }
173
+ handleSocketMessage(payload) {
174
+ const message = JSON.parse(payload);
175
+ if (typeof message.id !== "number") {
176
+ return;
177
+ }
178
+ const pending = this.pendingCommands.get(message.id);
179
+ if (!pending) {
180
+ return;
181
+ }
182
+ this.pendingCommands.delete(message.id);
183
+ if (message.error) {
184
+ pending.reject(new Error(message.error.message ?? "CDP command failed"));
185
+ return;
186
+ }
187
+ pending.resolve(message.result);
188
+ }
189
+ }