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,241 @@
1
+ import { buildMediaArtifactFromReference, parseQqMediaSegments } from "./qq-media-parser.js";
2
+ const QQ_MARKDOWN_DEFAULT_IMAGE_SIZE = { width: 512, height: 512 };
3
+ export function chunkTextForQq(text, limit = 5000, mode = "plain") {
4
+ if (mode === "markdown") {
5
+ return chunkMarkdownTextForQq(text, limit);
6
+ }
7
+ const chunks = [];
8
+ for (let index = 0; index < text.length; index += limit) {
9
+ chunks.push(text.slice(index, index + limit));
10
+ }
11
+ return chunks.length > 0 ? chunks : [""];
12
+ }
13
+ export class QqSender {
14
+ apiClient;
15
+ constructor(apiClient) {
16
+ this.apiClient = apiClient;
17
+ }
18
+ async deliver(draft) {
19
+ const providerMessageId = await this.deliverThroughApiClient(draft);
20
+ return {
21
+ jobId: draft.draftId,
22
+ sessionKey: draft.sessionKey,
23
+ providerMessageId,
24
+ deliveredAt: draft.createdAt
25
+ };
26
+ }
27
+ async sendTextSegment(target, text, replyToMessageId) {
28
+ if (!text) {
29
+ return null;
30
+ }
31
+ let lastProviderMessageId = null;
32
+ const preferMarkdown = shouldUseMarkdownForQq(text);
33
+ const chunkMode = preferMarkdown ? "markdown" : "plain";
34
+ for (const chunk of chunkTextForQq(text, 5000, chunkMode)) {
35
+ if (!chunk) {
36
+ continue;
37
+ }
38
+ if (target.chatType === "c2c") {
39
+ lastProviderMessageId = await this.apiClient.sendC2CMessage(target.peerId, chunk, replyToMessageId, {
40
+ preferMarkdown
41
+ });
42
+ continue;
43
+ }
44
+ if (target.chatType === "group") {
45
+ lastProviderMessageId = await this.apiClient.sendGroupMessage(target.peerId, chunk, replyToMessageId, {
46
+ preferMarkdown
47
+ });
48
+ continue;
49
+ }
50
+ }
51
+ return lastProviderMessageId;
52
+ }
53
+ async sendMediaArtifact(target, artifact, replyToMessageId) {
54
+ if (target.chatType === "c2c" && this.apiClient?.sendC2CMediaArtifact) {
55
+ return this.apiClient.sendC2CMediaArtifact(target.peerId, artifact, replyToMessageId);
56
+ }
57
+ if (target.chatType === "group" && this.apiClient?.sendGroupMediaArtifact) {
58
+ return this.apiClient.sendGroupMediaArtifact(target.peerId, artifact, replyToMessageId);
59
+ }
60
+ return null;
61
+ }
62
+ buildMediaFailureText(artifact, error) {
63
+ const filename = artifact.originalName || artifact.localPath || artifact.sourceUrl || "未知附件";
64
+ const reason = error instanceof Error ? error.message : String(error);
65
+ return `媒体发送失败:${filename}\n${reason}`;
66
+ }
67
+ async deliverThroughApiClient(draft) {
68
+ if (!this.apiClient) {
69
+ return null;
70
+ }
71
+ const replyToMessageId = draft.replyToMessageId ?? draft.draftId;
72
+ const target = parseSessionTarget(draft.sessionKey);
73
+ let lastProviderMessageId = null;
74
+ const deliveredArtifactKeys = new Set();
75
+ for (const segment of parseQqMediaSegments(draft.text)) {
76
+ if (segment.type === "text") {
77
+ lastProviderMessageId = await this.sendTextSegment(target, normalizeTextSegmentForQq(segment.text), replyToMessageId);
78
+ continue;
79
+ }
80
+ const artifact = buildMediaArtifactFromReference(segment.reference);
81
+ deliveredArtifactKeys.add(buildArtifactKey(artifact));
82
+ try {
83
+ lastProviderMessageId = await this.sendMediaArtifact(target, artifact, replyToMessageId);
84
+ }
85
+ catch (error) {
86
+ lastProviderMessageId = await this.sendTextSegment(target, this.buildMediaFailureText(artifact, error), replyToMessageId);
87
+ }
88
+ }
89
+ if (draft.mediaArtifacts?.length) {
90
+ for (const artifact of draft.mediaArtifacts) {
91
+ const artifactKey = buildArtifactKey(artifact);
92
+ if (deliveredArtifactKeys.has(artifactKey)) {
93
+ continue;
94
+ }
95
+ deliveredArtifactKeys.add(artifactKey);
96
+ try {
97
+ lastProviderMessageId = await this.sendMediaArtifact(target, artifact, replyToMessageId);
98
+ }
99
+ catch (error) {
100
+ lastProviderMessageId = await this.sendTextSegment(target, this.buildMediaFailureText(artifact, error), replyToMessageId);
101
+ }
102
+ }
103
+ }
104
+ if (lastProviderMessageId !== null) {
105
+ return lastProviderMessageId;
106
+ }
107
+ return this.sendTextSegment(target, normalizeTextSegmentForQq(draft.text), replyToMessageId);
108
+ }
109
+ }
110
+ function normalizeTextSegmentForQq(text) {
111
+ if (!text) {
112
+ return text;
113
+ }
114
+ return text.replace(/!\[(.*?)\]\((https?:\/\/[^)]+)\)/g, (_match, altText, url) => {
115
+ if (/^#\d+px\s+#\d+px$/i.test(altText.trim())) {
116
+ return `![${altText}](${url})`;
117
+ }
118
+ return `![#${QQ_MARKDOWN_DEFAULT_IMAGE_SIZE.width}px #${QQ_MARKDOWN_DEFAULT_IMAGE_SIZE.height}px](${url})`;
119
+ });
120
+ }
121
+ function shouldUseMarkdownForQq(text) {
122
+ if (!text.trim()) {
123
+ return false;
124
+ }
125
+ return (/```[\s\S]*```/.test(text) ||
126
+ /^\|.+\|\s*$/m.test(text) ||
127
+ /^\s*#{1,6}\s/m.test(text) ||
128
+ /^\s*>\s/m.test(text) ||
129
+ /^\s*[-*+]\s/m.test(text) ||
130
+ /^\s*\d+\.\s/m.test(text) ||
131
+ /!\[[^\]]*\]\([^)]+\)/.test(text) ||
132
+ /`[^`\n]+`/.test(text));
133
+ }
134
+ function chunkMarkdownTextForQq(text, limit) {
135
+ if (!text) {
136
+ return [""];
137
+ }
138
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
139
+ const chunks = [];
140
+ let current = "";
141
+ let inFence = false;
142
+ let fenceHeader = "";
143
+ const pushCurrent = (reopenFence) => {
144
+ if (!current.trim()) {
145
+ current = reopenFence && fenceHeader ? `${fenceHeader}\n` : "";
146
+ return;
147
+ }
148
+ let chunk = current;
149
+ if (inFence && fenceHeader) {
150
+ if (!chunk.endsWith("\n")) {
151
+ chunk += "\n";
152
+ }
153
+ chunk += "```";
154
+ }
155
+ chunks.push(chunk);
156
+ current = reopenFence && inFence && fenceHeader ? `${fenceHeader}\n` : "";
157
+ };
158
+ const appendLine = (line) => {
159
+ current = current ? `${current}\n${line}` : line;
160
+ };
161
+ for (const line of lines) {
162
+ const trimmedLine = line.trim();
163
+ const isFenceLine = /^```/.test(trimmedLine);
164
+ if (!current) {
165
+ appendLine(line);
166
+ }
167
+ else if (`${current}\n${line}`.length > limit) {
168
+ pushCurrent(true);
169
+ if (line.length > limit) {
170
+ const oversizedParts = splitOversizedMarkdownLine(line, limit, inFence, fenceHeader);
171
+ if (oversizedParts.length > 1) {
172
+ chunks.push(...oversizedParts.slice(0, -1));
173
+ current = oversizedParts.at(-1) ?? "";
174
+ }
175
+ else {
176
+ current = oversizedParts[0] ?? "";
177
+ }
178
+ }
179
+ else {
180
+ appendLine(line);
181
+ }
182
+ }
183
+ else {
184
+ appendLine(line);
185
+ }
186
+ if (isFenceLine) {
187
+ if (!inFence) {
188
+ fenceHeader = trimmedLine;
189
+ inFence = true;
190
+ }
191
+ else {
192
+ inFence = false;
193
+ fenceHeader = "";
194
+ }
195
+ }
196
+ }
197
+ if (current.trim()) {
198
+ if (inFence && fenceHeader) {
199
+ if (!current.endsWith("\n")) {
200
+ current += "\n";
201
+ }
202
+ current += "```";
203
+ }
204
+ chunks.push(current);
205
+ }
206
+ return chunks.length > 0 ? chunks : [""];
207
+ }
208
+ function splitOversizedMarkdownLine(line, limit, inFence, fenceHeader) {
209
+ const parts = [];
210
+ let rest = line;
211
+ while (rest.length > limit) {
212
+ const chunkBody = rest.slice(0, limit);
213
+ if (inFence && fenceHeader) {
214
+ parts.push(`${fenceHeader}\n${chunkBody}\n\`\`\``);
215
+ rest = `${fenceHeader}\n${rest.slice(limit)}`;
216
+ break;
217
+ }
218
+ parts.push(chunkBody);
219
+ rest = rest.slice(limit);
220
+ }
221
+ if (rest) {
222
+ parts.push(rest);
223
+ }
224
+ return parts;
225
+ }
226
+ function parseSessionTarget(sessionKey) {
227
+ const [, peerKey = ""] = sessionKey.split("::");
228
+ const segments = peerKey.split(":");
229
+ return {
230
+ chatType: segments[1] ?? "",
231
+ peerId: segments.slice(2).join(":")
232
+ };
233
+ }
234
+ function buildArtifactKey(artifact) {
235
+ return [
236
+ artifact.kind,
237
+ artifact.localPath || "",
238
+ artifact.sourceUrl || "",
239
+ artifact.originalName || ""
240
+ ].join("::");
241
+ }
@@ -0,0 +1,189 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { execFile } from "node:child_process";
4
+ import path from "node:path";
5
+ import os from "node:os";
6
+ import { mkdtemp, readFile, rm } from "node:fs/promises";
7
+ import { promisify } from "node:util";
8
+ const execFileAsync = promisify(execFile);
9
+ export function resolveQqSttConfigFromEnv(env) {
10
+ if (env.QQBOT_STT_ENABLED === "false") {
11
+ return null;
12
+ }
13
+ if (env.QQBOT_STT_PROVIDER === "local-whisper-cpp") {
14
+ const binaryPath = env.QQBOT_STT_BINARY_PATH;
15
+ const modelPath = env.QQBOT_STT_MODEL_PATH;
16
+ if (!binaryPath || !modelPath) {
17
+ return null;
18
+ }
19
+ return {
20
+ provider: "local-whisper-cpp",
21
+ binaryPath,
22
+ modelPath,
23
+ language: env.QQBOT_STT_LANGUAGE ?? "zh"
24
+ };
25
+ }
26
+ if (env.QQBOT_STT_PROVIDER === "volcengine-flash") {
27
+ const appId = env.QQBOT_STT_APP_ID;
28
+ const accessKey = env.QQBOT_STT_ACCESS_KEY;
29
+ const resourceId = env.QQBOT_STT_RESOURCE_ID;
30
+ const endpoint = env.QQBOT_STT_ENDPOINT ??
31
+ "https://openspeech.bytedance.com/api/v3/auc/bigmodel/recognize/flash";
32
+ if (!appId || !accessKey || !resourceId) {
33
+ return null;
34
+ }
35
+ return {
36
+ provider: "volcengine-flash",
37
+ endpoint: endpoint.replace(/\/+$/, ""),
38
+ appId,
39
+ accessKey,
40
+ resourceId,
41
+ model: env.QQBOT_STT_MODEL ?? "bigmodel"
42
+ };
43
+ }
44
+ const apiKey = env.QQBOT_STT_API_KEY ?? env.OPENAI_API_KEY;
45
+ if (!apiKey) {
46
+ return null;
47
+ }
48
+ const baseUrl = env.QQBOT_STT_BASE_URL ?? env.OPENAI_BASE_URL ?? "https://api.openai.com/v1";
49
+ const model = env.QQBOT_STT_MODEL ?? "whisper-1";
50
+ return {
51
+ provider: "openai-compatible",
52
+ baseUrl: baseUrl.replace(/\/+$/, ""),
53
+ apiKey,
54
+ model
55
+ };
56
+ }
57
+ export async function transcribeAudioFile(audioPath, config, fetchFn = fetch, commandRunner = execFile) {
58
+ if (config.provider === "local-whisper-cpp") {
59
+ return transcribeWithLocalWhisperCpp(audioPath, config, commandRunner);
60
+ }
61
+ if (config.provider === "volcengine-flash") {
62
+ return transcribeWithVolcengineFlash(audioPath, config, fetchFn);
63
+ }
64
+ const fileBuffer = readFileSync(audioPath);
65
+ const fileName = path.basename(audioPath);
66
+ const mimeType = inferAudioMimeType(fileName);
67
+ const form = new FormData();
68
+ form.append("file", new Blob([fileBuffer], { type: mimeType }), fileName);
69
+ form.append("model", config.model);
70
+ const response = await fetchFn(`${config.baseUrl}/audio/transcriptions`, {
71
+ method: "POST",
72
+ headers: {
73
+ Authorization: `Bearer ${config.apiKey}`
74
+ },
75
+ body: form
76
+ });
77
+ if (!response.ok) {
78
+ const detail = await response.text().catch(() => "");
79
+ throw new Error(`QQ STT failed (HTTP ${response.status}): ${detail.slice(0, 300)}`);
80
+ }
81
+ const payload = (await response.json());
82
+ const text = payload.text?.trim();
83
+ return text || null;
84
+ }
85
+ async function transcribeWithLocalWhisperCpp(audioPath, config, commandRunner) {
86
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), "qqbot-whisper-"));
87
+ const outputPrefix = path.join(tempDir, "transcript");
88
+ const args = [
89
+ "-m",
90
+ config.modelPath,
91
+ "-f",
92
+ audioPath,
93
+ "-otxt",
94
+ "-of",
95
+ outputPrefix,
96
+ "-np"
97
+ ];
98
+ if (config.language?.trim()) {
99
+ args.push("-l", config.language.trim());
100
+ }
101
+ try {
102
+ await promisifyCommandRunner(commandRunner)(config.binaryPath, args);
103
+ const transcriptPath = `${outputPrefix}.txt`;
104
+ const transcript = (await readFile(transcriptPath, "utf8")).trim();
105
+ return transcript || null;
106
+ }
107
+ finally {
108
+ await rm(tempDir, { recursive: true, force: true });
109
+ }
110
+ }
111
+ async function transcribeWithVolcengineFlash(audioPath, config, fetchFn) {
112
+ const audioBuffer = readFileSync(audioPath);
113
+ const requestId = randomUUID();
114
+ const base64Audio = audioBuffer.toString("base64");
115
+ const response = await fetchFn(config.endpoint, {
116
+ method: "POST",
117
+ headers: {
118
+ "Content-Type": "application/json",
119
+ "X-Api-App-Key": config.appId,
120
+ "X-Api-Access-Key": config.accessKey,
121
+ "X-Api-Resource-Id": config.resourceId,
122
+ "X-Api-Request-Id": requestId,
123
+ "X-Api-Sequence": "-1"
124
+ },
125
+ body: JSON.stringify({
126
+ user: {
127
+ uid: config.appId
128
+ },
129
+ audio: {
130
+ data: base64Audio
131
+ },
132
+ request: {
133
+ model_name: config.model
134
+ }
135
+ })
136
+ });
137
+ if (!response.ok) {
138
+ const detail = await response.text().catch(() => "");
139
+ throw new Error(`QQ STT failed (HTTP ${response.status}): ${detail.slice(0, 300)}`);
140
+ }
141
+ const payload = (await response.json());
142
+ const text = extractVolcengineTranscript(payload)?.trim();
143
+ return text || null;
144
+ }
145
+ function extractVolcengineTranscript(payload) {
146
+ if ("text" in payload) {
147
+ return payload.text;
148
+ }
149
+ if ("result" in payload) {
150
+ return payload.result?.text;
151
+ }
152
+ return null;
153
+ }
154
+ function promisifyCommandRunner(commandRunner) {
155
+ if (commandRunner === execFile) {
156
+ return execFileAsync;
157
+ }
158
+ return (file, args) => new Promise((resolve, reject) => {
159
+ commandRunner(file, args, (error, stdout, stderr) => {
160
+ if (error) {
161
+ reject(error);
162
+ return;
163
+ }
164
+ resolve({
165
+ stdout: typeof stdout === "string" ? stdout : String(stdout ?? ""),
166
+ stderr: typeof stderr === "string" ? stderr : String(stderr ?? "")
167
+ });
168
+ });
169
+ });
170
+ }
171
+ function inferAudioMimeType(fileName) {
172
+ const extension = path.extname(fileName).toLowerCase();
173
+ switch (extension) {
174
+ case ".wav":
175
+ return "audio/wav";
176
+ case ".mp3":
177
+ return "audio/mpeg";
178
+ case ".ogg":
179
+ return "audio/ogg";
180
+ case ".m4a":
181
+ return "audio/mp4";
182
+ case ".aac":
183
+ return "audio/aac";
184
+ case ".amr":
185
+ return "audio/amr";
186
+ default:
187
+ return "application/octet-stream";
188
+ }
189
+ }
@@ -0,0 +1,7 @@
1
+ export class DesktopDriverError extends Error {
2
+ reason;
3
+ constructor(message, reason) {
4
+ super(message);
5
+ this.reason = reason;
6
+ }
7
+ }
@@ -0,0 +1,7 @@
1
+ export var MediaArtifactKind;
2
+ (function (MediaArtifactKind) {
3
+ MediaArtifactKind["Image"] = "image";
4
+ MediaArtifactKind["Audio"] = "audio";
5
+ MediaArtifactKind["Video"] = "video";
6
+ MediaArtifactKind["File"] = "file";
7
+ })(MediaArtifactKind || (MediaArtifactKind = {}));
@@ -0,0 +1,7 @@
1
+ export var BridgeSessionStatus;
2
+ (function (BridgeSessionStatus) {
3
+ BridgeSessionStatus["Active"] = "active";
4
+ BridgeSessionStatus["NeedsRebind"] = "needs_rebind";
5
+ BridgeSessionStatus["DriverUnhealthy"] = "driver_unhealthy";
6
+ BridgeSessionStatus["Paused"] = "paused";
7
+ })(BridgeSessionStatus || (BridgeSessionStatus = {}));
@@ -0,0 +1,143 @@
1
+ import { BridgeSessionStatus } from "../../domain/src/session.js";
2
+ import { DesktopDriverError } from "../../domain/src/driver.js";
3
+ export class BridgeOrchestrator {
4
+ deps;
5
+ recentInboundFingerprints = new Map();
6
+ constructor(deps) {
7
+ this.deps = deps;
8
+ }
9
+ async handleInbound(message) {
10
+ const alreadySeen = await this.deps.transcriptStore.hasInbound(message.messageId);
11
+ if (alreadySeen) {
12
+ return;
13
+ }
14
+ await this.deps.sessionStore.withSessionLock(message.sessionKey, async () => {
15
+ const seenInsideLock = await this.deps.transcriptStore.hasInbound(message.messageId);
16
+ if (seenInsideLock) {
17
+ return;
18
+ }
19
+ if (this.isLikelyDuplicateInbound(message)) {
20
+ console.warn("[qq-codex-bridge] duplicate inbound suppressed", {
21
+ messageId: message.messageId,
22
+ sessionKey: message.sessionKey
23
+ });
24
+ return;
25
+ }
26
+ const existing = await this.deps.sessionStore.getSession(message.sessionKey);
27
+ if (!existing) {
28
+ const created = {
29
+ sessionKey: message.sessionKey,
30
+ accountKey: message.accountKey,
31
+ peerKey: message.peerKey,
32
+ chatType: message.chatType,
33
+ peerId: message.senderId,
34
+ codexThreadRef: null,
35
+ skillContextKey: null,
36
+ status: BridgeSessionStatus.Active,
37
+ lastInboundAt: message.receivedAt,
38
+ lastOutboundAt: null,
39
+ lastError: null
40
+ };
41
+ await this.deps.sessionStore.createSession(created);
42
+ }
43
+ await this.deps.transcriptStore.recordInbound(message);
44
+ this.rememberInbound(message);
45
+ try {
46
+ const deliveredDraftIds = new Set();
47
+ const deliveryErrors = [];
48
+ const handleDraft = async (draft) => {
49
+ if (deliveredDraftIds.has(draft.draftId)) {
50
+ return;
51
+ }
52
+ deliveredDraftIds.add(draft.draftId);
53
+ await this.deps.transcriptStore.recordOutbound(draft);
54
+ try {
55
+ await this.deps.qqEgress.deliver(draft);
56
+ }
57
+ catch (error) {
58
+ const reason = error instanceof Error ? error.message : String(error);
59
+ deliveryErrors.push(`${draft.draftId}: ${reason}`);
60
+ console.warn("[qq-codex-bridge] draft delivery failed", {
61
+ sessionKey: message.sessionKey,
62
+ messageId: message.messageId,
63
+ draftId: draft.draftId,
64
+ error: reason
65
+ });
66
+ }
67
+ };
68
+ const drafts = await this.deps.conversationProvider.runTurn(message, {
69
+ onDraft: handleDraft
70
+ });
71
+ for (const draft of drafts) {
72
+ await handleDraft(draft);
73
+ }
74
+ await this.deps.sessionStore.updateSessionStatus(message.sessionKey, BridgeSessionStatus.Active, deliveryErrors.length > 0 ? deliveryErrors.at(-1) ?? null : null);
75
+ }
76
+ catch (error) {
77
+ const lastError = error instanceof Error ? error.message : String(error);
78
+ if (isRecoverableTurnError(error)) {
79
+ await this.deps.sessionStore.updateSessionStatus(message.sessionKey, BridgeSessionStatus.Active, lastError);
80
+ console.warn("[qq-codex-bridge] recoverable turn error", {
81
+ messageId: message.messageId,
82
+ sessionKey: message.sessionKey,
83
+ error: lastError
84
+ });
85
+ return;
86
+ }
87
+ await this.deps.sessionStore.updateSessionStatus(message.sessionKey, BridgeSessionStatus.NeedsRebind, lastError);
88
+ throw error;
89
+ }
90
+ });
91
+ }
92
+ isLikelyDuplicateInbound(message) {
93
+ const record = this.recentInboundFingerprints.get(message.sessionKey);
94
+ if (!record) {
95
+ return false;
96
+ }
97
+ const receivedAtMs = Date.parse(message.receivedAt);
98
+ if (!Number.isFinite(receivedAtMs)) {
99
+ return false;
100
+ }
101
+ return (record.fingerprint === buildInboundFingerprint(message) &&
102
+ receivedAtMs - record.receivedAtMs >= 0 &&
103
+ receivedAtMs - record.receivedAtMs <= 90_000);
104
+ }
105
+ rememberInbound(message) {
106
+ const receivedAtMs = Date.parse(message.receivedAt);
107
+ if (!Number.isFinite(receivedAtMs)) {
108
+ return;
109
+ }
110
+ const now = receivedAtMs;
111
+ for (const [sessionKey, record] of this.recentInboundFingerprints.entries()) {
112
+ if (now - record.receivedAtMs > 120_000) {
113
+ this.recentInboundFingerprints.delete(sessionKey);
114
+ }
115
+ }
116
+ this.recentInboundFingerprints.set(message.sessionKey, {
117
+ fingerprint: buildInboundFingerprint(message),
118
+ receivedAtMs,
119
+ messageId: message.messageId
120
+ });
121
+ }
122
+ }
123
+ function isRecoverableTurnError(error) {
124
+ return error instanceof DesktopDriverError && error.reason === "reply_timeout";
125
+ }
126
+ function buildInboundFingerprint(message) {
127
+ const mediaFingerprint = (message.mediaArtifacts ?? [])
128
+ .map((artifact) => [
129
+ artifact.kind,
130
+ artifact.localPath || "",
131
+ artifact.sourceUrl || "",
132
+ artifact.originalName || ""
133
+ ].join("::"))
134
+ .join("|");
135
+ return [
136
+ message.accountKey,
137
+ message.sessionKey,
138
+ message.senderId,
139
+ message.chatType,
140
+ message.text.trim(),
141
+ mediaFingerprint
142
+ ].join("||");
143
+ }
@@ -0,0 +1,5 @@
1
+ export async function deliverDrafts(egress, drafts) {
2
+ for (const draft of drafts) {
3
+ await egress.deliver(draft);
4
+ }
5
+ }
@@ -0,0 +1,90 @@
1
+ import { buildQqbotSkillContext, shouldInjectQqbotSkillContext } from "./qqbot-skill-context.js";
2
+ export function buildCodexInboundText(message, options = {}) {
3
+ const baseText = message.text.trim();
4
+ const mediaArtifacts = message.mediaArtifacts ?? [];
5
+ const voiceTranscriptSection = buildVoiceTranscriptSection(mediaArtifacts);
6
+ const visibleSections = [baseText, voiceTranscriptSection].filter(Boolean);
7
+ const sections = [
8
+ visibleSections.length > 0 ? visibleSections.join("\n\n") : inferAttachmentOnlyPlaceholder(message)
9
+ ];
10
+ const hiddenContexts = [];
11
+ const attachmentContextArtifacts = mediaArtifacts.filter(shouldKeepAttachmentContext);
12
+ if (attachmentContextArtifacts.length > 0) {
13
+ hiddenContexts.push(buildHiddenAttachmentContext(attachmentContextArtifacts));
14
+ }
15
+ if ((options.includeSkillContext ?? true) && shouldInjectQqbotSkillContext(message)) {
16
+ hiddenContexts.push(wrapHiddenContext("QQBOT_RUNTIME_CONTEXT", buildQqbotSkillContext(message)));
17
+ }
18
+ if (hiddenContexts.length > 0) {
19
+ sections.push("", ...hiddenContexts);
20
+ }
21
+ return sections.join("\n");
22
+ }
23
+ function buildHiddenAttachmentContext(artifacts) {
24
+ const lines = ["QQBOT_ATTACHMENTS"];
25
+ for (const [index, artifact] of artifacts.entries()) {
26
+ lines.push(`${index + 1}. ${renderArtifactLabel(artifact)}:${artifact.originalName}`);
27
+ lines.push(`path=${artifact.localPath}`);
28
+ lines.push(`mime=${artifact.mimeType}`);
29
+ lines.push(`size=${artifact.fileSize}`);
30
+ const extractedText = artifact.extractedText?.trim();
31
+ if (extractedText && !isGenericAttachmentText(extractedText, artifact)) {
32
+ lines.push(`extracted=${extractedText}`);
33
+ }
34
+ const transcript = artifact.transcript?.trim();
35
+ if (transcript) {
36
+ lines.push(`transcript=${transcript}`);
37
+ }
38
+ const transcriptSource = artifact.transcriptSource?.trim();
39
+ if (transcriptSource) {
40
+ lines.push(`transcriptSource=${transcriptSource}`);
41
+ }
42
+ }
43
+ return wrapHiddenContext("QQBOT_ATTACHMENTS", lines.join("\n"));
44
+ }
45
+ function wrapHiddenContext(label, content) {
46
+ return [`<!-- ${label}`, content, "-->"].join("\n");
47
+ }
48
+ function inferAttachmentOnlyPlaceholder(message) {
49
+ if (message.mediaArtifacts?.length) {
50
+ const count = message.mediaArtifacts.length;
51
+ return `(用户发送了 ${count} 个 QQ 附件)`;
52
+ }
53
+ return "(用户消息未包含文本)";
54
+ }
55
+ function buildVoiceTranscriptSection(artifacts) {
56
+ const voiceArtifacts = artifacts.filter((artifact) => artifact.kind === "audio" && artifact.transcript?.trim());
57
+ if (voiceArtifacts.length === 0) {
58
+ return "";
59
+ }
60
+ if (voiceArtifacts.length === 1) {
61
+ return `[语音消息] ${voiceArtifacts[0].transcript.trim()}`;
62
+ }
63
+ return voiceArtifacts
64
+ .map((artifact, index) => `[语音${index + 1}] ${artifact.transcript.trim()}`)
65
+ .join("\n");
66
+ }
67
+ function renderArtifactLabel(artifact) {
68
+ switch (artifact.kind) {
69
+ case "image":
70
+ return "图片";
71
+ case "audio":
72
+ return "音频";
73
+ case "video":
74
+ return "视频";
75
+ case "file":
76
+ return "文件";
77
+ default:
78
+ return "附件";
79
+ }
80
+ }
81
+ function isGenericAttachmentText(text, artifact) {
82
+ const genericPrefixes = ["图片附件:", "语音附件:", "视频附件:", "文件附件:"];
83
+ return genericPrefixes.some((prefix) => text === `${prefix}${artifact.originalName}`);
84
+ }
85
+ function shouldKeepAttachmentContext(artifact) {
86
+ if (artifact.kind === "audio" && artifact.transcript?.trim()) {
87
+ return false;
88
+ }
89
+ return true;
90
+ }