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,62 @@
1
+ import { normalizeC2CMessage, normalizeGroupMessage } from "./qq-normalizer.js";
2
+ export class QqGateway {
3
+ config;
4
+ handler = null;
5
+ constructor(config) {
6
+ this.config = config;
7
+ }
8
+ async onMessage(handler) {
9
+ this.handler = handler;
10
+ }
11
+ async start() {
12
+ // no-op: this class only normalizes and dispatches payloads.
13
+ }
14
+ async stop() {
15
+ // no-op: this class only normalizes and dispatches payloads.
16
+ }
17
+ async dispatch(message) {
18
+ if (this.handler) {
19
+ await this.handler(message);
20
+ }
21
+ }
22
+ async dispatchPayload(event) {
23
+ if (this.isC2CEvent(event)) {
24
+ const mediaArtifacts = await this.downloadMediaArtifacts(event.d.attachments);
25
+ await this.dispatch(normalizeC2CMessage(event.d, this.config.accountKey, mediaArtifacts));
26
+ return;
27
+ }
28
+ if (this.isGroupEvent(event)) {
29
+ const mediaArtifacts = await this.downloadMediaArtifacts(event.d.attachments);
30
+ await this.dispatch(normalizeGroupMessage(event.d, this.config.accountKey, mediaArtifacts));
31
+ }
32
+ }
33
+ isC2CEvent(event) {
34
+ return event.t === "C2C_MESSAGE_CREATE";
35
+ }
36
+ isGroupEvent(event) {
37
+ return event.t === "GROUP_AT_MESSAGE_CREATE" || event.t === "GROUP_MESSAGE_CREATE";
38
+ }
39
+ async downloadMediaArtifacts(attachments) {
40
+ if (!attachments?.length || !this.config.mediaDownloader) {
41
+ return [];
42
+ }
43
+ const settledArtifacts = await Promise.allSettled(attachments.map((attachment) => this.config.mediaDownloader.downloadMediaArtifact({
44
+ sourceUrl: attachment.url,
45
+ originalName: attachment.filename ?? null,
46
+ mimeType: attachment.content_type ?? null,
47
+ fileSize: attachment.size ?? null,
48
+ voiceWavUrl: attachment.voice_wav_url ?? null,
49
+ asrReferText: attachment.asr_refer_text ?? null
50
+ })));
51
+ const artifacts = settledArtifacts.flatMap((result) => {
52
+ if (result.status === "fulfilled") {
53
+ return [result.value];
54
+ }
55
+ console.error("[qq-codex-bridge] qq attachment download failed", {
56
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason)
57
+ });
58
+ return [];
59
+ });
60
+ return artifacts;
61
+ }
62
+ }
@@ -0,0 +1,246 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { MediaArtifactKind } from "../../../domain/src/message.js";
5
+ import { transcribeAudioFile } from "./qq-stt.js";
6
+ export class QqMediaDownloader {
7
+ options;
8
+ fetchFn;
9
+ sttFetchFn;
10
+ constructor(options) {
11
+ this.options = options;
12
+ this.fetchFn = options.fetchFn ?? fetch;
13
+ this.sttFetchFn = options.sttFetchFn ?? fetch;
14
+ }
15
+ async downloadMediaArtifact(source) {
16
+ const normalizedSourceUrl = normalizeQqMediaUrl(source.voiceWavUrl || source.sourceUrl);
17
+ const response = await this.fetchFn(normalizedSourceUrl);
18
+ if (!response.ok) {
19
+ throw new Error(`QQ media download failed: ${response.status}`);
20
+ }
21
+ const arrayBuffer = await response.arrayBuffer();
22
+ const buffer = Buffer.from(arrayBuffer);
23
+ const mimeType = this.resolveMimeType(source.mimeType, response.headers.get("content-type"));
24
+ const originalName = this.resolveOriginalName(source.originalName, normalizedSourceUrl, mimeType, source.voiceWavUrl ?? null);
25
+ const kind = inferMediaArtifactKind(originalName, mimeType);
26
+ const localPath = this.writeLocalFile(originalName, buffer);
27
+ const transcript = await this.resolveTranscript({
28
+ kind,
29
+ localPath,
30
+ asrReferText: source.asrReferText
31
+ });
32
+ const artifact = {
33
+ kind,
34
+ sourceUrl: normalizedSourceUrl,
35
+ localPath,
36
+ mimeType,
37
+ fileSize: this.resolveFileSize(source.fileSize, response.headers.get("content-length"), buffer.length),
38
+ originalName,
39
+ transcript: transcript?.text ?? null,
40
+ transcriptSource: transcript?.source ?? null,
41
+ extractedText: extractReadableText({
42
+ kind,
43
+ originalName,
44
+ mimeType,
45
+ buffer,
46
+ transcript: transcript?.text ?? null
47
+ })
48
+ };
49
+ return artifact;
50
+ }
51
+ writeLocalFile(originalName, buffer) {
52
+ const resolvedBaseDir = path.resolve(this.options.baseDir);
53
+ mkdirSync(resolvedBaseDir, { recursive: true });
54
+ const parsed = path.parse(originalName);
55
+ const safeBaseName = sanitizeFileSegment(parsed.name || "qq-media");
56
+ const ext = parsed.ext || "";
57
+ const localPath = path.join(resolvedBaseDir, `${safeBaseName}-${randomUUID()}${ext}`);
58
+ writeFileSync(localPath, buffer);
59
+ return localPath;
60
+ }
61
+ resolveMimeType(sourceMimeType, responseMimeType) {
62
+ const mimeType = sourceMimeType ?? responseMimeType ?? "application/octet-stream";
63
+ return mimeType.split(";")[0]?.trim() || "application/octet-stream";
64
+ }
65
+ resolveOriginalName(originalName, sourceUrl, mimeType, voiceWavUrl) {
66
+ if (voiceWavUrl) {
67
+ const wavName = this.resolveNameFromUrl(voiceWavUrl);
68
+ if (wavName) {
69
+ return wavName;
70
+ }
71
+ }
72
+ if (originalName?.trim()) {
73
+ return originalName.trim();
74
+ }
75
+ const sourceName = this.resolveNameFromUrl(sourceUrl);
76
+ if (sourceName) {
77
+ return sourceName;
78
+ }
79
+ return `qq-media${extensionFromMimeType(mimeType)}`;
80
+ }
81
+ resolveNameFromUrl(sourceUrl) {
82
+ try {
83
+ const url = new URL(sourceUrl);
84
+ const urlName = path.basename(url.pathname);
85
+ if (urlName && urlName !== "/") {
86
+ return urlName;
87
+ }
88
+ }
89
+ catch {
90
+ // fall back to mime-derived extension
91
+ }
92
+ return null;
93
+ }
94
+ resolveFileSize(sourceFileSize, contentLength, fallbackSize) {
95
+ if (typeof sourceFileSize === "number" && Number.isFinite(sourceFileSize) && sourceFileSize >= 0) {
96
+ return sourceFileSize;
97
+ }
98
+ const parsedContentLength = contentLength ? Number(contentLength) : Number.NaN;
99
+ if (Number.isFinite(parsedContentLength) && parsedContentLength >= 0) {
100
+ return parsedContentLength;
101
+ }
102
+ return fallbackSize;
103
+ }
104
+ async resolveTranscript(input) {
105
+ if (input.kind !== MediaArtifactKind.Audio) {
106
+ return null;
107
+ }
108
+ const asrReferText = input.asrReferText?.trim();
109
+ const extension = path.extname(input.localPath).toLowerCase();
110
+ const shouldPreferAsrFallback = this.options.stt?.provider === "volcengine-flash" &&
111
+ [".amr", ".silk"].includes(extension) &&
112
+ Boolean(asrReferText);
113
+ if (this.options.stt && !shouldPreferAsrFallback) {
114
+ const startedAt = Date.now();
115
+ console.info("[qq-codex-bridge] qq stt started", {
116
+ provider: this.options.stt.provider,
117
+ file: input.localPath,
118
+ extension,
119
+ hasAsrReferText: Boolean(asrReferText)
120
+ });
121
+ try {
122
+ const text = await transcribeAudioFile(input.localPath, this.options.stt, this.sttFetchFn);
123
+ if (text) {
124
+ console.info("[qq-codex-bridge] qq stt completed", {
125
+ provider: this.options.stt.provider,
126
+ file: input.localPath,
127
+ durationMs: Date.now() - startedAt,
128
+ transcriptPreview: text.slice(0, 80)
129
+ });
130
+ return {
131
+ text,
132
+ source: "stt"
133
+ };
134
+ }
135
+ console.info("[qq-codex-bridge] qq stt produced no transcript", {
136
+ provider: this.options.stt.provider,
137
+ file: input.localPath,
138
+ durationMs: Date.now() - startedAt
139
+ });
140
+ }
141
+ catch (error) {
142
+ console.error("[qq-codex-bridge] qq stt failed", {
143
+ provider: this.options.stt.provider,
144
+ error: error instanceof Error ? error.message : String(error),
145
+ file: input.localPath,
146
+ durationMs: Date.now() - startedAt
147
+ });
148
+ }
149
+ }
150
+ if (asrReferText) {
151
+ console.info("[qq-codex-bridge] qq stt fallback used", {
152
+ source: "asr",
153
+ file: input.localPath,
154
+ transcriptPreview: asrReferText.slice(0, 80)
155
+ });
156
+ return {
157
+ text: asrReferText,
158
+ source: "asr"
159
+ };
160
+ }
161
+ return null;
162
+ }
163
+ }
164
+ function normalizeQqMediaUrl(sourceUrl) {
165
+ if (sourceUrl.startsWith("//")) {
166
+ return `https:${sourceUrl}`;
167
+ }
168
+ return sourceUrl;
169
+ }
170
+ function sanitizeFileSegment(segment) {
171
+ return segment.replace(/[^a-zA-Z0-9._-]+/g, "-");
172
+ }
173
+ function extensionFromMimeType(mimeType) {
174
+ switch (mimeType) {
175
+ case "image/png":
176
+ return ".png";
177
+ case "image/jpeg":
178
+ return ".jpg";
179
+ case "image/gif":
180
+ return ".gif";
181
+ case "image/webp":
182
+ return ".webp";
183
+ case "audio/mpeg":
184
+ return ".mp3";
185
+ case "audio/wav":
186
+ case "audio/x-wav":
187
+ return ".wav";
188
+ case "video/mp4":
189
+ return ".mp4";
190
+ default:
191
+ return "";
192
+ }
193
+ }
194
+ export function inferMediaArtifactKind(originalName, mimeType) {
195
+ if (mimeType.startsWith("image/")) {
196
+ return MediaArtifactKind.Image;
197
+ }
198
+ if (mimeType.startsWith("audio/")) {
199
+ return MediaArtifactKind.Audio;
200
+ }
201
+ if (mimeType.startsWith("video/")) {
202
+ return MediaArtifactKind.Video;
203
+ }
204
+ const extension = path.extname(originalName).toLowerCase();
205
+ if ([".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].includes(extension)) {
206
+ return MediaArtifactKind.Image;
207
+ }
208
+ if ([".amr", ".mp3", ".wav", ".ogg", ".aac", ".flac", ".silk", ".m4a"].includes(extension)) {
209
+ return MediaArtifactKind.Audio;
210
+ }
211
+ if ([".mp4", ".mov", ".avi", ".mkv", ".webm"].includes(extension)) {
212
+ return MediaArtifactKind.Video;
213
+ }
214
+ return MediaArtifactKind.File;
215
+ }
216
+ function extractReadableText(input) {
217
+ if (input.kind === MediaArtifactKind.Audio && input.transcript?.trim()) {
218
+ return input.transcript.trim();
219
+ }
220
+ if (isTextLikeArtifact(input.originalName, input.mimeType)) {
221
+ const text = input.buffer.toString("utf8").trim();
222
+ return text ? text.slice(0, 4000) : null;
223
+ }
224
+ switch (input.kind) {
225
+ case MediaArtifactKind.Image:
226
+ return `图片附件:${input.originalName}`;
227
+ case MediaArtifactKind.Audio:
228
+ return `语音附件:${input.originalName}`;
229
+ case MediaArtifactKind.Video:
230
+ return `视频附件:${input.originalName}`;
231
+ case MediaArtifactKind.File:
232
+ return `文件附件:${input.originalName}`;
233
+ default:
234
+ return null;
235
+ }
236
+ }
237
+ function isTextLikeArtifact(originalName, mimeType) {
238
+ if (mimeType.startsWith("text/")) {
239
+ return true;
240
+ }
241
+ if (["application/json", "application/xml"].includes(mimeType)) {
242
+ return true;
243
+ }
244
+ const extension = path.extname(originalName).toLowerCase();
245
+ return [".txt", ".md", ".json", ".csv", ".log", ".xml", ".yaml", ".yml"].includes(extension);
246
+ }
@@ -0,0 +1,144 @@
1
+ import path from "node:path";
2
+ import { statSync, existsSync } from "node:fs";
3
+ import { inferMediaArtifactKind } from "./qq-media-downloader.js";
4
+ const MEDIA_SEGMENT_PATTERN = /<qqmedia>([\s\S]*?)<\/qqmedia>|!\[[^\]]*\]\(([^)]+)\)/g;
5
+ export function parseQqMediaSegments(text) {
6
+ if (!text) {
7
+ return [];
8
+ }
9
+ const segments = [];
10
+ let lastIndex = 0;
11
+ for (const match of text.matchAll(MEDIA_SEGMENT_PATTERN)) {
12
+ const fullMatch = match[0];
13
+ const matchIndex = match.index ?? 0;
14
+ if (matchIndex > lastIndex) {
15
+ segments.push({ type: "text", text: text.slice(lastIndex, matchIndex) });
16
+ }
17
+ const qqMediaReference = match[1]?.trim();
18
+ const markdownImageReference = match[2]?.trim();
19
+ if (qqMediaReference && isSupportedMediaReference(qqMediaReference)) {
20
+ segments.push({ type: "media", reference: qqMediaReference });
21
+ }
22
+ else if (markdownImageReference && shouldRouteMarkdownImageAsMedia(markdownImageReference)) {
23
+ segments.push({ type: "media", reference: markdownImageReference });
24
+ }
25
+ else {
26
+ segments.push({ type: "text", text: fullMatch });
27
+ }
28
+ lastIndex = matchIndex + fullMatch.length;
29
+ }
30
+ if (lastIndex < text.length) {
31
+ segments.push({ type: "text", text: text.slice(lastIndex) });
32
+ }
33
+ return mergeAdjacentTextSegments(segments);
34
+ }
35
+ export function buildMediaArtifactFromReference(reference) {
36
+ const mimeType = inferMimeType(reference);
37
+ const originalName = inferOriginalName(reference);
38
+ const isLocal = !reference.startsWith("http://") && !reference.startsWith("https://");
39
+ const fileSize = isLocal && existsSync(reference) ? statSync(reference).size : 0;
40
+ return {
41
+ kind: inferMediaArtifactKind(originalName, mimeType),
42
+ sourceUrl: reference,
43
+ localPath: reference,
44
+ mimeType,
45
+ fileSize,
46
+ originalName
47
+ };
48
+ }
49
+ function mergeAdjacentTextSegments(segments) {
50
+ const merged = [];
51
+ for (const segment of segments) {
52
+ const previous = merged.at(-1);
53
+ if (segment.type === "text" && previous?.type === "text") {
54
+ previous.text += segment.text;
55
+ continue;
56
+ }
57
+ if (segment.type === "text" && segment.text.length === 0) {
58
+ continue;
59
+ }
60
+ merged.push(segment);
61
+ }
62
+ return merged;
63
+ }
64
+ function isSupportedMediaReference(reference) {
65
+ return hasRecognizedExtension(reference) || reference.startsWith("/") || reference.startsWith("http://") || reference.startsWith("https://");
66
+ }
67
+ function shouldRouteMarkdownImageAsMedia(reference) {
68
+ return isLocalLikeReference(reference) || reference.startsWith("data:image/");
69
+ }
70
+ function isLocalLikeReference(reference) {
71
+ return reference.startsWith("/") || reference.startsWith("./") || reference.startsWith("../") || /^[A-Za-z]:[\\/]/.test(reference);
72
+ }
73
+ function hasRecognizedExtension(reference) {
74
+ const extension = path.extname(stripQuery(reference)).toLowerCase();
75
+ return [
76
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
77
+ ".mp3", ".wav", ".ogg", ".aac", ".flac", ".silk",
78
+ ".mp4", ".mov", ".avi", ".mkv", ".webm",
79
+ ".pdf", ".txt", ".md", ".json", ".csv", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".zip"
80
+ ].includes(extension);
81
+ }
82
+ function inferMimeType(reference) {
83
+ const extension = path.extname(stripQuery(reference)).toLowerCase();
84
+ switch (extension) {
85
+ case ".png":
86
+ return "image/png";
87
+ case ".jpg":
88
+ case ".jpeg":
89
+ return "image/jpeg";
90
+ case ".gif":
91
+ return "image/gif";
92
+ case ".webp":
93
+ return "image/webp";
94
+ case ".bmp":
95
+ return "image/bmp";
96
+ case ".mp3":
97
+ return "audio/mpeg";
98
+ case ".wav":
99
+ return "audio/wav";
100
+ case ".ogg":
101
+ return "audio/ogg";
102
+ case ".aac":
103
+ return "audio/aac";
104
+ case ".flac":
105
+ return "audio/flac";
106
+ case ".silk":
107
+ return "audio/silk";
108
+ case ".mp4":
109
+ return "video/mp4";
110
+ case ".mov":
111
+ return "video/quicktime";
112
+ case ".avi":
113
+ return "video/x-msvideo";
114
+ case ".mkv":
115
+ return "video/x-matroska";
116
+ case ".webm":
117
+ return "video/webm";
118
+ case ".txt":
119
+ return "text/plain";
120
+ case ".md":
121
+ return "text/markdown";
122
+ case ".json":
123
+ return "application/json";
124
+ case ".csv":
125
+ return "text/csv";
126
+ case ".pdf":
127
+ return "application/pdf";
128
+ default:
129
+ return "application/octet-stream";
130
+ }
131
+ }
132
+ function inferOriginalName(reference) {
133
+ try {
134
+ const url = new URL(reference);
135
+ const name = path.basename(url.pathname);
136
+ return name || "qq-media";
137
+ }
138
+ catch {
139
+ return path.basename(reference) || "qq-media";
140
+ }
141
+ }
142
+ function stripQuery(reference) {
143
+ return reference.split("?")[0] ?? reference;
144
+ }
@@ -0,0 +1,35 @@
1
+ import { buildPeerKey, buildSessionKey } from "../../../orchestrator/src/session-key.js";
2
+ export function normalizeC2CMessage(event, accountKey, mediaArtifacts = []) {
3
+ const peerKey = buildPeerKey({
4
+ chatType: "c2c",
5
+ peerId: event.author.user_openid
6
+ });
7
+ return {
8
+ messageId: event.id,
9
+ accountKey,
10
+ sessionKey: buildSessionKey({ accountKey, peerKey }),
11
+ peerKey,
12
+ chatType: "c2c",
13
+ senderId: event.author.user_openid,
14
+ text: event.content,
15
+ ...(mediaArtifacts.length > 0 ? { mediaArtifacts } : {}),
16
+ receivedAt: event.timestamp
17
+ };
18
+ }
19
+ export function normalizeGroupMessage(event, accountKey, mediaArtifacts = []) {
20
+ const peerKey = buildPeerKey({
21
+ chatType: "group",
22
+ peerId: event.group_openid
23
+ });
24
+ return {
25
+ messageId: event.id,
26
+ accountKey,
27
+ sessionKey: buildSessionKey({ accountKey, peerKey }),
28
+ peerKey,
29
+ chatType: "group",
30
+ senderId: event.author.member_openid,
31
+ text: event.content,
32
+ ...(mediaArtifacts.length > 0 ? { mediaArtifacts } : {}),
33
+ receivedAt: event.timestamp
34
+ };
35
+ }