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,38 @@
1
+ import { buildMediaArtifactFromReference, parseQqMediaSegments } from "../../adapters/qq/src/qq-media-parser.js";
2
+ export function enrichQqOutboundDraft(draft) {
3
+ const parsedArtifacts = parseQqMediaSegments(draft.text)
4
+ .filter((segment) => segment.type === "media")
5
+ .map((segment) => buildMediaArtifactFromReference(segment.reference));
6
+ const mergedArtifacts = dedupeArtifacts([
7
+ ...(draft.mediaArtifacts ?? []),
8
+ ...parsedArtifacts
9
+ ]);
10
+ if (mergedArtifacts.length === 0) {
11
+ return draft;
12
+ }
13
+ return {
14
+ ...draft,
15
+ mediaArtifacts: mergedArtifacts
16
+ };
17
+ }
18
+ function dedupeArtifacts(artifacts) {
19
+ const seen = new Set();
20
+ const deduped = [];
21
+ for (const artifact of artifacts) {
22
+ const key = buildArtifactKey(artifact);
23
+ if (seen.has(key)) {
24
+ continue;
25
+ }
26
+ seen.add(key);
27
+ deduped.push(artifact);
28
+ }
29
+ return deduped;
30
+ }
31
+ function buildArtifactKey(artifact) {
32
+ return [
33
+ artifact.kind,
34
+ artifact.localPath || "",
35
+ artifact.sourceUrl || "",
36
+ artifact.originalName || ""
37
+ ].join("::");
38
+ }
@@ -0,0 +1,51 @@
1
+ export function formatQqOutboundDraft(draft) {
2
+ const formattedText = formatQqOutboundText(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 formatQqOutboundText(text, mediaArtifacts) {
12
+ const hasMediaArtifacts = mediaArtifacts.length > 0;
13
+ const pathSet = new Set(mediaArtifacts.flatMap((artifact) => [artifact.localPath, artifact.sourceUrl].filter((value) => typeof value === "string" && value.trim().length > 0)));
14
+ const lines = text.split("\n");
15
+ const normalizedLines = [];
16
+ for (const line of lines) {
17
+ const trimmed = line.trim();
18
+ if (!trimmed) {
19
+ normalizedLines.push("");
20
+ continue;
21
+ }
22
+ if (pathSet.has(stripWrapping(trimmed))) {
23
+ continue;
24
+ }
25
+ if (containsOnlyArtifactPath(trimmed, pathSet)) {
26
+ continue;
27
+ }
28
+ if (hasMediaArtifacts && shouldDropInternalBridgeLine(trimmed)) {
29
+ continue;
30
+ }
31
+ normalizedLines.push(line);
32
+ }
33
+ return normalizedLines
34
+ .join("\n")
35
+ .replace(/\n{3,}/g, "\n\n")
36
+ .trim();
37
+ }
38
+ function containsOnlyArtifactPath(line, paths) {
39
+ const normalized = stripWrapping(line);
40
+ return Array.from(paths).some((path) => normalized === stripWrapping(path));
41
+ }
42
+ function stripWrapping(value) {
43
+ return value.replace(/^[-*]\s*/, "").replace(/^`+/, "").replace(/`+$/, "").trim();
44
+ }
45
+ function shouldDropInternalBridgeLine(line) {
46
+ return (line.includes("QQBot 桥接程序收到你上传附件后") ||
47
+ line.includes("临时落盘的运行目录路径") ||
48
+ line.includes("这里看到的是相对路径") ||
49
+ line.includes("runtime/media/") ||
50
+ line.includes("/Volumes/"));
51
+ }
@@ -0,0 +1,13 @@
1
+ export function buildQqbotSkillContext(message) {
2
+ const lines = [
3
+ `会话类型:${message.chatType === "group" ? "QQ 群聊" : "QQ 私聊"}`,
4
+ "给 QQ 用户发图片、语音、视频、文件时,必须输出 <qqmedia>绝对路径或URL</qqmedia>。",
5
+ "如果已经准备好本地文件,直接输出媒体标签,不要解释 bridge、runtime/media、相对路径或内部实现。",
6
+ "多个媒体用多个 <qqmedia> 标签;正文只保留用户真正需要看的说明。",
7
+ "大小限制:图片 30MB、语音 20MB、视频/文件 100MB。"
8
+ ];
9
+ return lines.join("\n");
10
+ }
11
+ export function shouldInjectQqbotSkillContext(message) {
12
+ return message.accountKey.startsWith("qqbot:");
13
+ }
@@ -0,0 +1,6 @@
1
+ export function buildPeerKey(input) {
2
+ return input.chatType === "c2c" ? `qq:c2c:${input.peerId}` : `qq:group:${input.peerId}`;
3
+ }
4
+ export function buildSessionKey(input) {
5
+ return `${input.accountKey}::${input.peerKey}`;
6
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { createHash } from "node:crypto";
2
+ function digest(value) {
3
+ return createHash("sha256").update(value).digest("hex");
4
+ }
5
+ export class SqliteTranscriptStore {
6
+ db;
7
+ constructor(db) {
8
+ this.db = db;
9
+ }
10
+ async recordInbound(message) {
11
+ this.db
12
+ .prepare(`INSERT OR IGNORE INTO message_ledger (
13
+ message_id, session_key, direction, qq_message_ref, codex_turn_ref,
14
+ content_digest, payload_json, created_at
15
+ ) VALUES (?, ?, 'inbound', ?, NULL, ?, ?, ?)`)
16
+ .run(message.messageId, message.sessionKey, message.messageId, digest(message.text), JSON.stringify(message), message.receivedAt);
17
+ }
18
+ async recordOutbound(draft) {
19
+ this.db
20
+ .prepare(`INSERT OR IGNORE INTO delivery_jobs (
21
+ job_id, session_key, status, attempt_count, payload_json, last_error, created_at, updated_at
22
+ ) VALUES (?, ?, 'pending', 0, ?, NULL, ?, ?)`)
23
+ .run(draft.draftId, draft.sessionKey, JSON.stringify(draft), draft.createdAt, draft.createdAt);
24
+ }
25
+ async hasInbound(messageId) {
26
+ const row = this.db.prepare(`SELECT 1 FROM message_ledger WHERE message_id = ? AND direction = 'inbound'`).get(messageId);
27
+ return row !== undefined && row !== null;
28
+ }
29
+ async listRecentConversation(sessionKey, limit) {
30
+ const rows = this.db
31
+ .prepare(`SELECT direction, text, created_at AS createdAt
32
+ FROM (
33
+ SELECT direction,
34
+ json_extract(payload_json, '$.text') AS text,
35
+ created_at
36
+ FROM message_ledger
37
+ WHERE session_key = ?
38
+
39
+ UNION ALL
40
+
41
+ SELECT 'outbound' AS direction,
42
+ json_extract(payload_json, '$.text') AS text,
43
+ created_at
44
+ FROM delivery_jobs
45
+ WHERE session_key = ?
46
+ )
47
+ WHERE text IS NOT NULL AND text != ''
48
+ ORDER BY createdAt DESC
49
+ LIMIT ?`)
50
+ .all(sessionKey, sessionKey, limit);
51
+ return rows.reverse();
52
+ }
53
+ }
@@ -0,0 +1,80 @@
1
+ import { randomUUID } from "node:crypto";
2
+ export class SqliteSessionStore {
3
+ db;
4
+ sessionLockTails = new Map();
5
+ constructor(db) {
6
+ this.db = db;
7
+ }
8
+ async getSession(sessionKey) {
9
+ const row = this.db
10
+ .prepare(`SELECT session_key AS sessionKey,
11
+ account_key AS accountKey,
12
+ peer_key AS peerKey,
13
+ chat_type AS chatType,
14
+ peer_id AS peerId,
15
+ codex_thread_ref AS codexThreadRef,
16
+ skill_context_key AS skillContextKey,
17
+ status,
18
+ last_inbound_at AS lastInboundAt,
19
+ last_outbound_at AS lastOutboundAt,
20
+ last_error AS lastError
21
+ FROM bridge_sessions
22
+ WHERE session_key = ?`)
23
+ .get(sessionKey);
24
+ return row ?? null;
25
+ }
26
+ async createSession(session) {
27
+ this.db
28
+ .prepare(`INSERT INTO bridge_sessions (
29
+ 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);
33
+ }
34
+ async updateSessionStatus(sessionKey, status, lastError = null) {
35
+ this.db
36
+ .prepare(`UPDATE bridge_sessions SET status = ?, last_error = ? WHERE session_key = ?`)
37
+ .run(status, lastError, sessionKey);
38
+ }
39
+ async updateBinding(sessionKey, codexThreadRef) {
40
+ this.db
41
+ .prepare(`UPDATE bridge_sessions SET codex_thread_ref = ? WHERE session_key = ?`)
42
+ .run(codexThreadRef, sessionKey);
43
+ }
44
+ async updateSkillContextKey(sessionKey, skillContextKey) {
45
+ this.db
46
+ .prepare(`UPDATE bridge_sessions SET skill_context_key = ? WHERE session_key = ?`)
47
+ .run(skillContextKey, sessionKey);
48
+ }
49
+ async withSessionLock(sessionKey, work) {
50
+ // Queue same-session work in-process so the SQLite lock row reflects a real exclusive section.
51
+ const previousTail = this.sessionLockTails.get(sessionKey) ?? Promise.resolve();
52
+ let releaseCurrent;
53
+ const currentTail = new Promise((resolve) => {
54
+ releaseCurrent = resolve;
55
+ });
56
+ const queuedTail = previousTail.then(() => currentTail);
57
+ this.sessionLockTails.set(sessionKey, queuedTail);
58
+ await previousTail;
59
+ const owner = randomUUID();
60
+ const lockedAt = new Date().toISOString();
61
+ const expiresAt = new Date(Date.now() + 60_000).toISOString();
62
+ this.db
63
+ .prepare(`DELETE FROM session_locks WHERE session_key = ?`)
64
+ .run(sessionKey);
65
+ this.db
66
+ .prepare(`INSERT OR REPLACE INTO session_locks (session_key, owner, locked_at, expires_at)
67
+ VALUES (?, ?, ?, ?)`)
68
+ .run(sessionKey, owner, lockedAt, expiresAt);
69
+ try {
70
+ return await work();
71
+ }
72
+ finally {
73
+ this.db.prepare(`DELETE FROM session_locks WHERE session_key = ? AND owner = ?`).run(sessionKey, owner);
74
+ releaseCurrent();
75
+ if (this.sessionLockTails.get(sessionKey) === queuedTail) {
76
+ this.sessionLockTails.delete(sessionKey);
77
+ }
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { createRequire } from "node:module";
4
+ const require = createRequire(import.meta.url);
5
+ const BetterSqlite3 = require("better-sqlite3");
6
+ export function createSqliteDatabase(filePath) {
7
+ if (filePath !== ":memory:") {
8
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
9
+ }
10
+ const db = new BetterSqlite3(filePath);
11
+ db.exec(`
12
+ CREATE TABLE IF NOT EXISTS bridge_sessions (
13
+ session_key TEXT PRIMARY KEY,
14
+ account_key TEXT NOT NULL,
15
+ peer_key TEXT NOT NULL,
16
+ chat_type TEXT NOT NULL,
17
+ peer_id TEXT NOT NULL,
18
+ codex_thread_ref TEXT,
19
+ skill_context_key TEXT,
20
+ status TEXT NOT NULL,
21
+ last_inbound_at TEXT,
22
+ last_outbound_at TEXT,
23
+ last_error TEXT
24
+ );
25
+
26
+ CREATE TABLE IF NOT EXISTS message_ledger (
27
+ message_id TEXT PRIMARY KEY,
28
+ session_key TEXT NOT NULL,
29
+ direction TEXT NOT NULL,
30
+ qq_message_ref TEXT,
31
+ codex_turn_ref TEXT,
32
+ content_digest TEXT NOT NULL,
33
+ payload_json TEXT NOT NULL,
34
+ created_at TEXT NOT NULL
35
+ );
36
+
37
+ CREATE TABLE IF NOT EXISTS delivery_jobs (
38
+ job_id TEXT PRIMARY KEY,
39
+ session_key TEXT NOT NULL,
40
+ status TEXT NOT NULL,
41
+ attempt_count INTEGER NOT NULL,
42
+ payload_json TEXT NOT NULL,
43
+ last_error TEXT,
44
+ created_at TEXT NOT NULL,
45
+ updated_at TEXT NOT NULL
46
+ );
47
+
48
+ CREATE TABLE IF NOT EXISTS session_locks (
49
+ session_key TEXT PRIMARY KEY,
50
+ owner TEXT NOT NULL,
51
+ locked_at TEXT NOT NULL,
52
+ expires_at TEXT NOT NULL
53
+ );
54
+ `);
55
+ ensureColumn(db, "bridge_sessions", "skill_context_key", "TEXT");
56
+ return db;
57
+ }
58
+ function ensureColumn(db, tableName, columnName, columnDefinition) {
59
+ const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
60
+ const hasColumn = columns.some((column) => column.name === columnName);
61
+ if (!hasColumn) {
62
+ db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDefinition}`);
63
+ }
64
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "qq-codex-bridge",
3
+ "version": "0.1.0",
4
+ "description": "A bridge between QQ Official Bot and Codex Desktop, with media relay, STT, thread management, and incremental reply delivery.",
5
+ "type": "module",
6
+ "homepage": "https://github.com/983033995/qq-codex-bridge",
7
+ "bugs": {
8
+ "url": "https://github.com/983033995/qq-codex-bridge/issues"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/983033995/qq-codex-bridge.git"
13
+ },
14
+ "keywords": [
15
+ "bridge",
16
+ "cdp",
17
+ "codex",
18
+ "electron",
19
+ "qq",
20
+ "qq-bot",
21
+ "typescript"
22
+ ],
23
+ "bin": {
24
+ "qq-codex-bridge": "bin/qq-codex-bridge.js"
25
+ },
26
+ "files": [
27
+ "bin",
28
+ "dist/apps",
29
+ "dist/packages",
30
+ ".env.example",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "scripts": {
38
+ "build": "tsc -p tsconfig.json",
39
+ "dev": "tsx --env-file=.env apps/bridge-daemon/src/dev.ts",
40
+ "start": "node dist/apps/bridge-daemon/src/cli.js",
41
+ "debug:codex-workers": "tsx apps/bridge-daemon/src/debug-codex-workers.ts",
42
+ "test": "vitest run",
43
+ "test:watch": "vitest",
44
+ "check": "tsc -p tsconfig.json --noEmit",
45
+ "prepare-release": "pnpm run build && npm pack --dry-run",
46
+ "prepublishOnly": "pnpm run build"
47
+ },
48
+ "dependencies": {
49
+ "better-sqlite3": "^11.7.0",
50
+ "ws": "^8.18.0",
51
+ "zod": "^3.24.2"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^22.13.10",
55
+ "@types/ws": "^8.5.14",
56
+ "tsx": "^4.19.3",
57
+ "typescript": "^5.8.2",
58
+ "vitest": "^3.1.1"
59
+ }
60
+ }