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.
- package/.env.example +58 -0
- package/LICENSE +21 -0
- package/README.md +453 -0
- package/bin/qq-codex-bridge.js +11 -0
- package/dist/apps/bridge-daemon/src/bootstrap.js +100 -0
- package/dist/apps/bridge-daemon/src/cli.js +141 -0
- package/dist/apps/bridge-daemon/src/config.js +109 -0
- package/dist/apps/bridge-daemon/src/debug-codex-workers.js +309 -0
- package/dist/apps/bridge-daemon/src/dev-launch.js +73 -0
- package/dist/apps/bridge-daemon/src/dev.js +28 -0
- package/dist/apps/bridge-daemon/src/http-server.js +36 -0
- package/dist/apps/bridge-daemon/src/main.js +57 -0
- package/dist/apps/bridge-daemon/src/thread-command-handler.js +197 -0
- package/dist/packages/adapters/codex-desktop/src/cdp-session.js +189 -0
- package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +1259 -0
- package/dist/packages/adapters/codex-desktop/src/composer-heuristics.js +11 -0
- package/dist/packages/adapters/codex-desktop/src/health.js +7 -0
- package/dist/packages/adapters/codex-desktop/src/reply-parser.js +10 -0
- package/dist/packages/adapters/qq/src/qq-api-client.js +232 -0
- package/dist/packages/adapters/qq/src/qq-channel-adapter.js +22 -0
- package/dist/packages/adapters/qq/src/qq-gateway-client.js +295 -0
- package/dist/packages/adapters/qq/src/qq-gateway-session-store.js +64 -0
- package/dist/packages/adapters/qq/src/qq-gateway.js +62 -0
- package/dist/packages/adapters/qq/src/qq-media-downloader.js +246 -0
- package/dist/packages/adapters/qq/src/qq-media-parser.js +144 -0
- package/dist/packages/adapters/qq/src/qq-normalizer.js +35 -0
- package/dist/packages/adapters/qq/src/qq-sender.js +241 -0
- package/dist/packages/adapters/qq/src/qq-stt.js +189 -0
- package/dist/packages/domain/src/driver.js +7 -0
- package/dist/packages/domain/src/message.js +7 -0
- package/dist/packages/domain/src/session.js +7 -0
- package/dist/packages/orchestrator/src/bridge-orchestrator.js +143 -0
- package/dist/packages/orchestrator/src/job-runner.js +5 -0
- package/dist/packages/orchestrator/src/media-context.js +90 -0
- package/dist/packages/orchestrator/src/qq-outbound-draft.js +38 -0
- package/dist/packages/orchestrator/src/qq-outbound-format.js +51 -0
- package/dist/packages/orchestrator/src/qqbot-skill-context.js +13 -0
- package/dist/packages/orchestrator/src/session-key.js +6 -0
- package/dist/packages/ports/src/conversation.js +1 -0
- package/dist/packages/ports/src/qq.js +1 -0
- package/dist/packages/ports/src/store.js +1 -0
- package/dist/packages/store/src/message-repo.js +53 -0
- package/dist/packages/store/src/session-repo.js +80 -0
- package/dist/packages/store/src/sqlite.js +64 -0
- 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 ``;
|
|
117
|
+
}
|
|
118
|
+
return ``;
|
|
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 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,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
|
+
}
|