qq-codex-bridge 0.1.3 → 0.1.4
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 +62 -0
- package/README.md +232 -287
- package/bin/chatgpt-desktop.js +2 -0
- package/bin/qq-codex-weixin-gateway.js +14 -0
- package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
- package/dist/apps/bridge-daemon/src/cli.js +5 -1
- package/dist/apps/bridge-daemon/src/config.js +168 -37
- package/dist/apps/bridge-daemon/src/http-server.js +23 -11
- package/dist/apps/bridge-daemon/src/main.js +163 -29
- package/dist/apps/bridge-daemon/src/thread-command-handler.js +309 -26
- package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
- package/dist/apps/weixin-gateway/src/cli.js +446 -0
- package/dist/apps/weixin-gateway/src/config.js +135 -0
- package/dist/apps/weixin-gateway/src/dev.js +2 -0
- package/dist/apps/weixin-gateway/src/message-store.js +50 -0
- package/dist/apps/weixin-gateway/src/server.js +216 -0
- package/dist/apps/weixin-gateway/src/state.js +163 -0
- package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
- package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
- package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
- package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
- package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
- package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
- package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
- package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
- package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
- package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
- package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
- package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
- package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
- package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
- package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
- package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
- package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
- package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
- package/dist/packages/ports/src/chat.js +1 -0
- package/dist/packages/store/src/session-repo.js +16 -3
- package/dist/packages/store/src/sqlite.js +3 -0
- package/package.json +8 -2
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import("../dist/apps/weixin-gateway/src/cli.js")
|
|
4
|
+
.then(({ runCliFromProcess }) => runCliFromProcess())
|
|
5
|
+
.catch((error) => {
|
|
6
|
+
console.error(
|
|
7
|
+
"[qq-codex-weixin-gateway] fatal:",
|
|
8
|
+
error instanceof Error ? error.message : String(error)
|
|
9
|
+
);
|
|
10
|
+
if (error instanceof Error && error.stack) {
|
|
11
|
+
console.error(" stack:", error.stack);
|
|
12
|
+
}
|
|
13
|
+
process.exitCode = 1;
|
|
14
|
+
});
|
|
@@ -2,7 +2,12 @@ import path from "node:path";
|
|
|
2
2
|
import { QqApiClient } from "../../../packages/adapters/qq/src/qq-api-client.js";
|
|
3
3
|
import { createQqChannelAdapter } from "../../../packages/adapters/qq/src/qq-channel-adapter.js";
|
|
4
4
|
import { FileQqGatewaySessionStore } from "../../../packages/adapters/qq/src/qq-gateway-session-store.js";
|
|
5
|
+
import { createWeixinChannelAdapter } from "../../../packages/adapters/weixin/src/weixin-channel-adapter.js";
|
|
5
6
|
import { CdpSession } from "../../../packages/adapters/codex-desktop/src/cdp-session.js";
|
|
7
|
+
import { CodexAppServerDriver } from "../../../packages/adapters/codex-desktop/src/codex-app-server-driver.js";
|
|
8
|
+
import { CodexDesktopAppUiNotificationForwarder } from "../../../packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js";
|
|
9
|
+
import { CodexLocalRolloutReader } from "../../../packages/adapters/codex-desktop/src/codex-local-rollout-reader.js";
|
|
10
|
+
import { CodexLocalSubmissionReader } from "../../../packages/adapters/codex-desktop/src/codex-local-submission-reader.js";
|
|
6
11
|
import { CodexDesktopDriver } from "../../../packages/adapters/codex-desktop/src/codex-desktop-driver.js";
|
|
7
12
|
import { BridgeSessionStatus } from "../../../packages/domain/src/session.js";
|
|
8
13
|
import { BridgeOrchestrator } from "../../../packages/orchestrator/src/bridge-orchestrator.js";
|
|
@@ -10,37 +15,89 @@ import { buildCodexInboundText } from "../../../packages/orchestrator/src/media-
|
|
|
10
15
|
import { formatQqOutboundDraft } from "../../../packages/orchestrator/src/qq-outbound-format.js";
|
|
11
16
|
import { enrichQqOutboundDraft } from "../../../packages/orchestrator/src/qq-outbound-draft.js";
|
|
12
17
|
import { shouldInjectQqbotSkillContext } from "../../../packages/orchestrator/src/qqbot-skill-context.js";
|
|
18
|
+
import { formatWeixinOutboundDraft } from "../../../packages/orchestrator/src/weixin-outbound-format.js";
|
|
13
19
|
import { SqliteTranscriptStore } from "../../../packages/store/src/message-repo.js";
|
|
14
20
|
import { SqliteSessionStore } from "../../../packages/store/src/session-repo.js";
|
|
15
21
|
import { createSqliteDatabase } from "../../../packages/store/src/sqlite.js";
|
|
16
22
|
import { loadConfigFromEnv } from "./config.js";
|
|
23
|
+
import { ChatgptDesktopProvider } from "../../../packages/adapters/chatgpt-desktop/src/bridge-provider.js";
|
|
17
24
|
const INTERNAL_TURN_EVENT_PATH = "/internal/codex-turn-events";
|
|
25
|
+
function formatDraftForQq(draft) {
|
|
26
|
+
return formatQqOutboundDraft(enrichQqOutboundDraft(draft));
|
|
27
|
+
}
|
|
28
|
+
function formatDraftForWeixin(draft) {
|
|
29
|
+
return formatWeixinOutboundDraft(enrichQqOutboundDraft(draft));
|
|
30
|
+
}
|
|
18
31
|
export function bootstrap() {
|
|
19
32
|
const config = loadConfigFromEnv(process.env);
|
|
20
33
|
const db = createSqliteDatabase(config.databasePath);
|
|
21
34
|
const sessionStore = new SqliteSessionStore(db);
|
|
22
35
|
const transcriptStore = new SqliteTranscriptStore(db);
|
|
23
|
-
const
|
|
24
|
-
|
|
36
|
+
const runtimeDir = path.dirname(config.databasePath);
|
|
37
|
+
const useDomTransport = process.env.CODEX_DESKTOP_TRANSPORT === "dom";
|
|
38
|
+
const forwardAppServerUiEvents = process.env.CODEX_APP_SERVER_FORWARD_UI_EVENTS === "1";
|
|
39
|
+
const cdpSession = new CdpSession({
|
|
40
|
+
appName: config.codexDesktop.appName,
|
|
41
|
+
remoteDebuggingPort: config.codexDesktop.remoteDebuggingPort
|
|
25
42
|
});
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
const legacyDomDriver = new CodexDesktopDriver(cdpSession, {
|
|
44
|
+
localRolloutReader: new CodexLocalRolloutReader(),
|
|
45
|
+
localSubmissionReader: new CodexLocalSubmissionReader()
|
|
46
|
+
});
|
|
47
|
+
const codexDriver = useDomTransport
|
|
48
|
+
? legacyDomDriver
|
|
49
|
+
: new CodexAppServerDriver({
|
|
50
|
+
controlFallback: legacyDomDriver,
|
|
51
|
+
notificationForwarder: forwardAppServerUiEvents
|
|
52
|
+
? new CodexDesktopAppUiNotificationForwarder(cdpSession)
|
|
53
|
+
: null
|
|
54
|
+
});
|
|
55
|
+
const qqAdapters = config.qqBots.map((bot) => {
|
|
56
|
+
const accountKey = `qqbot:${bot.accountId}`;
|
|
57
|
+
const qqApiClient = new QqApiClient(bot.appId, bot.clientSecret, {
|
|
58
|
+
markdownSupport: bot.markdownSupport
|
|
59
|
+
});
|
|
60
|
+
const qqGatewaySessionStore = new FileQqGatewaySessionStore(path.join(runtimeDir, `qq-gateway-session-${safePathSegment(bot.accountId)}.json`), accountKey, bot.appId);
|
|
61
|
+
return {
|
|
30
62
|
accountKey,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
63
|
+
adapter: createQqChannelAdapter({
|
|
64
|
+
accountKey,
|
|
65
|
+
appId: bot.appId,
|
|
66
|
+
apiClient: qqApiClient,
|
|
67
|
+
sessionStore: qqGatewaySessionStore,
|
|
68
|
+
mediaDownloadDir: path.join(runtimeDir, "media", "qq", safePathSegment(bot.accountId)),
|
|
69
|
+
stt: bot.stt
|
|
70
|
+
}),
|
|
71
|
+
sessionStore: qqGatewaySessionStore
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
const defaultQqAdapter = qqAdapters[0];
|
|
75
|
+
if (!defaultQqAdapter) {
|
|
76
|
+
throw new Error("at least one QQ bot must be configured");
|
|
77
|
+
}
|
|
78
|
+
const adapters = {
|
|
79
|
+
qq: defaultQqAdapter.adapter,
|
|
80
|
+
qqByAccountKey: Object.fromEntries(qqAdapters.map((entry) => [entry.accountKey, entry.adapter])),
|
|
81
|
+
codexDesktop: codexDriver
|
|
41
82
|
};
|
|
42
|
-
|
|
43
|
-
|
|
83
|
+
let desktopTurnTail = Promise.resolve();
|
|
84
|
+
const runWithDesktopTurnLock = async (work) => {
|
|
85
|
+
const previous = desktopTurnTail;
|
|
86
|
+
let release;
|
|
87
|
+
desktopTurnTail = new Promise((resolve) => {
|
|
88
|
+
release = resolve;
|
|
89
|
+
});
|
|
90
|
+
await previous;
|
|
91
|
+
try {
|
|
92
|
+
return await work();
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
release();
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const runCodexTurn = async (work) => useDomTransport ? runWithDesktopTurnLock(work) : work();
|
|
99
|
+
const codexConversationProvider = {
|
|
100
|
+
runTurn: async (message, options) => runCodexTurn(async () => {
|
|
44
101
|
await adapters.codexDesktop.ensureAppReady();
|
|
45
102
|
const session = await sessionStore.getSession(message.sessionKey);
|
|
46
103
|
const currentBinding = session
|
|
@@ -61,19 +118,23 @@ export function bootstrap() {
|
|
|
61
118
|
includeSkillContext: shouldIncludeSkillContext
|
|
62
119
|
})
|
|
63
120
|
});
|
|
64
|
-
|
|
65
|
-
|
|
121
|
+
const stableBinding = await resolveStableBinding(adapters.codexDesktop, binding);
|
|
122
|
+
if (session?.codexThreadRef !== stableBinding.codexThreadRef) {
|
|
123
|
+
await sessionStore.updateBinding(message.sessionKey, stableBinding.codexThreadRef);
|
|
66
124
|
}
|
|
67
125
|
if (shouldIncludeSkillContext) {
|
|
68
|
-
|
|
126
|
+
const stableSkillContextKey = skillContextKey !== null
|
|
127
|
+
? `${stableBinding.codexThreadRef ?? "unbound"}:qqbot-skill-v2`
|
|
128
|
+
: null;
|
|
129
|
+
await sessionStore.updateSkillContextKey(message.sessionKey, stableSkillContextKey);
|
|
69
130
|
}
|
|
70
|
-
const drafts = await adapters.codexDesktop.collectAssistantReply(
|
|
131
|
+
const drafts = await adapters.codexDesktop.collectAssistantReply(stableBinding, {
|
|
71
132
|
onDraft: options?.onDraft
|
|
72
133
|
? async (draft) => {
|
|
73
|
-
await options.onDraft(
|
|
134
|
+
await options.onDraft({
|
|
74
135
|
...draft,
|
|
75
136
|
replyToMessageId: message.messageId
|
|
76
|
-
})
|
|
137
|
+
});
|
|
77
138
|
}
|
|
78
139
|
: undefined,
|
|
79
140
|
onTurnEvent: async (event) => {
|
|
@@ -86,26 +147,92 @@ export function bootstrap() {
|
|
|
86
147
|
});
|
|
87
148
|
}
|
|
88
149
|
});
|
|
89
|
-
return drafts.map((draft) =>
|
|
150
|
+
return drafts.map((draft) => ({
|
|
90
151
|
...draft,
|
|
91
152
|
replyToMessageId: message.messageId
|
|
92
|
-
}))
|
|
153
|
+
}));
|
|
154
|
+
})
|
|
155
|
+
};
|
|
156
|
+
const chatgptProvider = new ChatgptDesktopProvider({ outDir: "runtime/media/chatgpt" });
|
|
157
|
+
const conversationProvider = {
|
|
158
|
+
runTurn: async (message, options) => {
|
|
159
|
+
const session = await sessionStore.getSession(message.sessionKey);
|
|
160
|
+
const effectiveProvider = session?.conversationProvider ?? config.conversationProvider;
|
|
161
|
+
if (effectiveProvider === "chatgpt-desktop") {
|
|
162
|
+
return chatgptProvider.runTurn(message, options);
|
|
163
|
+
}
|
|
164
|
+
return codexConversationProvider.runTurn(message, options);
|
|
93
165
|
}
|
|
94
166
|
};
|
|
95
|
-
const
|
|
167
|
+
const createChannelOrchestrator = (egress, draftFormatter) => new BridgeOrchestrator({
|
|
96
168
|
sessionStore,
|
|
97
169
|
transcriptStore,
|
|
98
170
|
conversationProvider,
|
|
99
|
-
qqEgress:
|
|
171
|
+
qqEgress: egress,
|
|
172
|
+
draftFormatter
|
|
100
173
|
});
|
|
174
|
+
const qqOrchestrators = Object.fromEntries(qqAdapters.map((entry) => [
|
|
175
|
+
entry.accountKey,
|
|
176
|
+
createChannelOrchestrator(entry.adapter.egress, formatDraftForQq)
|
|
177
|
+
]));
|
|
178
|
+
const weixinAdapters = config.weixinAccounts
|
|
179
|
+
.filter((account) => account.enabled && account.egressBaseUrl && account.egressToken)
|
|
180
|
+
.map((account) => {
|
|
181
|
+
const accountKey = `weixin:${account.accountId}`;
|
|
182
|
+
return {
|
|
183
|
+
accountKey,
|
|
184
|
+
adapter: createWeixinChannelAdapter({
|
|
185
|
+
accountKey,
|
|
186
|
+
webhookPath: account.webhookPath,
|
|
187
|
+
egressBaseUrl: account.egressBaseUrl,
|
|
188
|
+
egressToken: account.egressToken
|
|
189
|
+
})
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
const weixinOrchestrators = Object.fromEntries(weixinAdapters.map((entry) => [
|
|
193
|
+
entry.accountKey,
|
|
194
|
+
createChannelOrchestrator(entry.adapter.egress, formatDraftForWeixin)
|
|
195
|
+
]));
|
|
196
|
+
const defaultWeixinAdapter = weixinAdapters[0]?.adapter;
|
|
197
|
+
const defaultWeixinOrchestrator = weixinAdapters[0]
|
|
198
|
+
? weixinOrchestrators[weixinAdapters[0].accountKey]
|
|
199
|
+
: undefined;
|
|
200
|
+
const channelOrchestrators = {
|
|
201
|
+
qq: qqOrchestrators[defaultQqAdapter.accountKey],
|
|
202
|
+
...(defaultWeixinOrchestrator ? { weixin: defaultWeixinOrchestrator } : {}),
|
|
203
|
+
byAccountKey: {
|
|
204
|
+
...qqOrchestrators,
|
|
205
|
+
...weixinOrchestrators
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
const allAdapters = {
|
|
209
|
+
...adapters,
|
|
210
|
+
...(defaultWeixinAdapter ? { weixin: defaultWeixinAdapter } : {}),
|
|
211
|
+
weixinByAccountKey: Object.fromEntries(weixinAdapters.map((entry) => [entry.accountKey, entry.adapter]))
|
|
212
|
+
};
|
|
101
213
|
return {
|
|
102
214
|
config,
|
|
103
215
|
db,
|
|
104
216
|
sessionStore,
|
|
105
217
|
transcriptStore,
|
|
106
|
-
adapters,
|
|
107
|
-
orchestrator,
|
|
108
|
-
|
|
218
|
+
adapters: allAdapters,
|
|
219
|
+
orchestrator: channelOrchestrators.qq,
|
|
220
|
+
orchestrators: channelOrchestrators,
|
|
221
|
+
qqGatewaySessionStore: defaultQqAdapter.sessionStore,
|
|
222
|
+
chatgptDriver: chatgptProvider.desktopDriver
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
async function resolveStableBinding(desktopDriver, binding) {
|
|
226
|
+
if (!binding.codexThreadRef?.startsWith("cdp-target:")) {
|
|
227
|
+
return binding;
|
|
228
|
+
}
|
|
229
|
+
const currentThread = (await desktopDriver.listRecentThreads(200)).find((thread) => thread.isCurrent);
|
|
230
|
+
if (!currentThread) {
|
|
231
|
+
return binding;
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
sessionKey: binding.sessionKey,
|
|
235
|
+
codexThreadRef: currentThread.threadRef
|
|
109
236
|
};
|
|
110
237
|
}
|
|
111
238
|
async function postTurnEvent(port, event) {
|
|
@@ -127,3 +254,6 @@ async function postTurnEvent(port, event) {
|
|
|
127
254
|
}
|
|
128
255
|
}
|
|
129
256
|
export { INTERNAL_TURN_EVENT_PATH };
|
|
257
|
+
function safePathSegment(value) {
|
|
258
|
+
return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "default";
|
|
259
|
+
}
|
|
@@ -52,7 +52,11 @@ export async function runCli(rawArgs, deps = {}) {
|
|
|
52
52
|
startupPollIntervalMs: Number(env.CODEX_CDP_POLL_INTERVAL_MS ?? "500")
|
|
53
53
|
});
|
|
54
54
|
writeStdout(`[qq-codex-bridge] codex desktop ready { launched: ${String(result.launched)}, remoteDebuggingPort: ${config.codexDesktop.remoteDebuggingPort} }`);
|
|
55
|
-
await startBridge();
|
|
55
|
+
const runtime = await startBridge();
|
|
56
|
+
const channels = Array.isArray(runtime?.channels)
|
|
57
|
+
? runtime.channels
|
|
58
|
+
: ["qq"];
|
|
59
|
+
writeStdout(`[qq-codex-bridge] channels active: ${channels.join(", ")}`);
|
|
56
60
|
return 0;
|
|
57
61
|
}
|
|
58
62
|
catch (error) {
|
|
@@ -1,4 +1,41 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
const qqBotConfigSchema = z.object({
|
|
3
|
+
accountId: z.string().min(1),
|
|
4
|
+
appId: z.string().min(1),
|
|
5
|
+
clientSecret: z.string().min(1),
|
|
6
|
+
markdownSupport: z.boolean(),
|
|
7
|
+
stt: z
|
|
8
|
+
.union([
|
|
9
|
+
z.object({
|
|
10
|
+
provider: z.literal("local-whisper-cpp"),
|
|
11
|
+
binaryPath: z.string().min(1),
|
|
12
|
+
modelPath: z.string().min(1),
|
|
13
|
+
language: z.string().min(1).optional()
|
|
14
|
+
}),
|
|
15
|
+
z.object({
|
|
16
|
+
provider: z.literal("openai-compatible"),
|
|
17
|
+
baseUrl: z.string().url(),
|
|
18
|
+
apiKey: z.string().min(1),
|
|
19
|
+
model: z.string().min(1)
|
|
20
|
+
}),
|
|
21
|
+
z.object({
|
|
22
|
+
provider: z.literal("volcengine-flash"),
|
|
23
|
+
endpoint: z.string().url(),
|
|
24
|
+
appId: z.string().min(1),
|
|
25
|
+
accessKey: z.string().min(1),
|
|
26
|
+
resourceId: z.string().min(1),
|
|
27
|
+
model: z.string().min(1)
|
|
28
|
+
})
|
|
29
|
+
])
|
|
30
|
+
.nullable()
|
|
31
|
+
});
|
|
32
|
+
const weixinConfigSchema = z.object({
|
|
33
|
+
enabled: z.boolean(),
|
|
34
|
+
accountId: z.string().min(1),
|
|
35
|
+
webhookPath: z.string().startsWith("/"),
|
|
36
|
+
egressBaseUrl: z.string().url().nullable(),
|
|
37
|
+
egressToken: z.string().min(1).nullable()
|
|
38
|
+
});
|
|
2
39
|
export const appConfigSchema = z.object({
|
|
3
40
|
databasePath: z.string().min(1),
|
|
4
41
|
runtime: z.object({
|
|
@@ -6,41 +43,31 @@ export const appConfigSchema = z.object({
|
|
|
6
43
|
listenPort: z.number().int().positive(),
|
|
7
44
|
webhookPath: z.string().startsWith("/")
|
|
8
45
|
}),
|
|
9
|
-
qqBot:
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
stt: z
|
|
14
|
-
.union([
|
|
15
|
-
z.object({
|
|
16
|
-
provider: z.literal("local-whisper-cpp"),
|
|
17
|
-
binaryPath: z.string().min(1),
|
|
18
|
-
modelPath: z.string().min(1),
|
|
19
|
-
language: z.string().min(1).optional()
|
|
20
|
-
}),
|
|
21
|
-
z.object({
|
|
22
|
-
provider: z.literal("openai-compatible"),
|
|
23
|
-
baseUrl: z.string().url(),
|
|
24
|
-
apiKey: z.string().min(1),
|
|
25
|
-
model: z.string().min(1)
|
|
26
|
-
}),
|
|
27
|
-
z.object({
|
|
28
|
-
provider: z.literal("volcengine-flash"),
|
|
29
|
-
endpoint: z.string().url(),
|
|
30
|
-
appId: z.string().min(1),
|
|
31
|
-
accessKey: z.string().min(1),
|
|
32
|
-
resourceId: z.string().min(1),
|
|
33
|
-
model: z.string().min(1)
|
|
34
|
-
})
|
|
35
|
-
])
|
|
36
|
-
.nullable()
|
|
37
|
-
}),
|
|
46
|
+
qqBot: qqBotConfigSchema,
|
|
47
|
+
qqBots: z.array(qqBotConfigSchema).min(1),
|
|
48
|
+
weixin: weixinConfigSchema,
|
|
49
|
+
weixinAccounts: z.array(weixinConfigSchema),
|
|
38
50
|
codexDesktop: z.object({
|
|
39
51
|
appName: z.string().min(1),
|
|
40
52
|
remoteDebuggingPort: z.number().int().positive()
|
|
41
|
-
})
|
|
53
|
+
}),
|
|
54
|
+
conversationProvider: z.enum(["codex-desktop", "chatgpt-desktop"])
|
|
42
55
|
});
|
|
43
56
|
export function loadConfigFromEnv(env) {
|
|
57
|
+
const fallbackQqBot = {
|
|
58
|
+
accountId: env.QQBOT_ACCOUNT_ID ?? "default",
|
|
59
|
+
appId: env.QQBOT_APP_ID,
|
|
60
|
+
clientSecret: env.QQBOT_CLIENT_SECRET,
|
|
61
|
+
markdownSupport: env.QQBOT_MARKDOWN_SUPPORT === "true",
|
|
62
|
+
stt: resolveSttConfig(env)
|
|
63
|
+
};
|
|
64
|
+
const fallbackWeixin = {
|
|
65
|
+
enabled: env.WEIXIN_ENABLED === "true",
|
|
66
|
+
accountId: env.WEIXIN_ACCOUNT_ID ?? "default",
|
|
67
|
+
webhookPath: env.WEIXIN_WEBHOOK_PATH ?? "/webhooks/weixin",
|
|
68
|
+
egressBaseUrl: env.WEIXIN_EGRESS_BASE_URL ?? null,
|
|
69
|
+
egressToken: env.WEIXIN_EGRESS_TOKEN ?? null
|
|
70
|
+
};
|
|
44
71
|
return appConfigSchema.parse({
|
|
45
72
|
databasePath: env.QQ_CODEX_DATABASE_PATH ?? "runtime/qq-codex-bridge.sqlite",
|
|
46
73
|
runtime: {
|
|
@@ -48,18 +75,78 @@ export function loadConfigFromEnv(env) {
|
|
|
48
75
|
listenPort: Number(env.QQ_CODEX_LISTEN_PORT ?? "3100"),
|
|
49
76
|
webhookPath: env.QQ_CODEX_WEBHOOK_PATH ?? "/webhooks/qq"
|
|
50
77
|
},
|
|
51
|
-
qqBot:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
stt: resolveSttConfig(env)
|
|
56
|
-
},
|
|
78
|
+
qqBot: fallbackQqBot,
|
|
79
|
+
qqBots: resolveQqBotConfigs(env, fallbackQqBot),
|
|
80
|
+
weixin: fallbackWeixin,
|
|
81
|
+
weixinAccounts: resolveWeixinConfigs(env, fallbackWeixin),
|
|
57
82
|
codexDesktop: {
|
|
58
83
|
appName: env.CODEX_APP_NAME ?? "Codex",
|
|
59
84
|
remoteDebuggingPort: Number(env.CODEX_REMOTE_DEBUGGING_PORT ?? "9229")
|
|
60
|
-
}
|
|
85
|
+
},
|
|
86
|
+
conversationProvider: (env.BRIDGE_CONVERSATION_PROVIDER === "chatgpt-desktop"
|
|
87
|
+
? "chatgpt-desktop"
|
|
88
|
+
: "codex-desktop")
|
|
61
89
|
});
|
|
62
90
|
}
|
|
91
|
+
function resolveQqBotConfigs(env, fallback) {
|
|
92
|
+
const jsonConfigs = parseJsonArray(env.QQBOTS_JSON ?? env.QQBOT_ACCOUNTS_JSON);
|
|
93
|
+
if (jsonConfigs) {
|
|
94
|
+
return jsonConfigs.map((item, index) => {
|
|
95
|
+
const record = asRecord(item);
|
|
96
|
+
return {
|
|
97
|
+
accountId: stringValue(record.accountId ?? record.id, `bot${index + 1}`),
|
|
98
|
+
appId: stringValue(record.appId ?? record.appID ?? record.qqbotAppId, ""),
|
|
99
|
+
clientSecret: stringValue(record.clientSecret ?? record.secret ?? record.qqbotClientSecret, ""),
|
|
100
|
+
markdownSupport: booleanValue(record.markdownSupport, fallback.markdownSupport),
|
|
101
|
+
stt: fallback.stt
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const accountIds = splitList(env.QQBOT_ACCOUNT_IDS);
|
|
106
|
+
if (accountIds.length > 0) {
|
|
107
|
+
return accountIds.map((accountId, index) => {
|
|
108
|
+
const suffix = envNameSuffix(accountId);
|
|
109
|
+
return {
|
|
110
|
+
accountId,
|
|
111
|
+
appId: env[`QQBOT_${suffix}_APP_ID`] ?? (index === 0 ? fallback.appId : undefined),
|
|
112
|
+
clientSecret: env[`QQBOT_${suffix}_CLIENT_SECRET`] ?? (index === 0 ? fallback.clientSecret : undefined),
|
|
113
|
+
markdownSupport: booleanEnv(env[`QQBOT_${suffix}_MARKDOWN_SUPPORT`], fallback.markdownSupport),
|
|
114
|
+
stt: fallback.stt
|
|
115
|
+
};
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return [fallback];
|
|
119
|
+
}
|
|
120
|
+
function resolveWeixinConfigs(env, fallback) {
|
|
121
|
+
const jsonConfigs = parseJsonArray(env.WEIXIN_ACCOUNTS_JSON);
|
|
122
|
+
if (jsonConfigs) {
|
|
123
|
+
return jsonConfigs.map((item, index) => {
|
|
124
|
+
const record = asRecord(item);
|
|
125
|
+
const accountId = stringValue(record.accountId ?? record.id, `account${index + 1}`);
|
|
126
|
+
return {
|
|
127
|
+
enabled: booleanValue(record.enabled, true),
|
|
128
|
+
accountId,
|
|
129
|
+
webhookPath: stringValue(record.webhookPath, `/webhooks/weixin/${accountId}`),
|
|
130
|
+
egressBaseUrl: nullableString(record.egressBaseUrl ?? record.baseUrl),
|
|
131
|
+
egressToken: nullableString(record.egressToken ?? record.token)
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const accountIds = splitList(env.WEIXIN_ACCOUNT_IDS);
|
|
136
|
+
if (accountIds.length > 0) {
|
|
137
|
+
return accountIds.map((accountId, index) => {
|
|
138
|
+
const suffix = envNameSuffix(accountId);
|
|
139
|
+
return {
|
|
140
|
+
enabled: booleanEnv(env[`WEIXIN_${suffix}_ENABLED`], true),
|
|
141
|
+
accountId,
|
|
142
|
+
webhookPath: env[`WEIXIN_${suffix}_WEBHOOK_PATH`] ?? `/webhooks/weixin/${accountId}`,
|
|
143
|
+
egressBaseUrl: env[`WEIXIN_${suffix}_EGRESS_BASE_URL`] ?? (index === 0 ? fallback.egressBaseUrl : null),
|
|
144
|
+
egressToken: env[`WEIXIN_${suffix}_EGRESS_TOKEN`] ?? (index === 0 ? fallback.egressToken : null)
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return fallback.enabled ? [fallback] : [];
|
|
149
|
+
}
|
|
63
150
|
function resolveSttConfig(env) {
|
|
64
151
|
if (env.QQBOT_STT_ENABLED === "false") {
|
|
65
152
|
return null;
|
|
@@ -107,3 +194,47 @@ function resolveSttConfig(env) {
|
|
|
107
194
|
model
|
|
108
195
|
};
|
|
109
196
|
}
|
|
197
|
+
function parseJsonArray(value) {
|
|
198
|
+
if (!value?.trim()) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
const parsed = JSON.parse(value);
|
|
202
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
203
|
+
}
|
|
204
|
+
function asRecord(value) {
|
|
205
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
206
|
+
? value
|
|
207
|
+
: {};
|
|
208
|
+
}
|
|
209
|
+
function stringValue(value, fallback) {
|
|
210
|
+
const text = String(value ?? "").trim();
|
|
211
|
+
return text || fallback;
|
|
212
|
+
}
|
|
213
|
+
function nullableString(value) {
|
|
214
|
+
const text = String(value ?? "").trim();
|
|
215
|
+
return text || null;
|
|
216
|
+
}
|
|
217
|
+
function booleanValue(value, fallback) {
|
|
218
|
+
if (typeof value === "boolean") {
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
if (typeof value === "string") {
|
|
222
|
+
return booleanEnv(value, fallback);
|
|
223
|
+
}
|
|
224
|
+
return fallback;
|
|
225
|
+
}
|
|
226
|
+
function booleanEnv(value, fallback) {
|
|
227
|
+
if (value === undefined) {
|
|
228
|
+
return fallback;
|
|
229
|
+
}
|
|
230
|
+
return value === "true" || value === "1";
|
|
231
|
+
}
|
|
232
|
+
function splitList(value) {
|
|
233
|
+
return (value ?? "")
|
|
234
|
+
.split(",")
|
|
235
|
+
.map((item) => item.trim())
|
|
236
|
+
.filter(Boolean);
|
|
237
|
+
}
|
|
238
|
+
function envNameSuffix(value) {
|
|
239
|
+
return value.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase();
|
|
240
|
+
}
|
|
@@ -1,27 +1,39 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
export function createQqWebhookServer(deps) {
|
|
3
3
|
return createJsonServer({
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
routes: [
|
|
5
|
+
{
|
|
6
|
+
routePath: deps.webhookPath,
|
|
7
|
+
dispatchPayload: deps.ingress.dispatchPayload,
|
|
8
|
+
onDispatchError: deps.onDispatchError
|
|
9
|
+
}
|
|
10
|
+
]
|
|
7
11
|
});
|
|
8
12
|
}
|
|
9
13
|
export function createInternalTurnEventServer(deps) {
|
|
10
14
|
return createJsonServer({
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
routes: [
|
|
16
|
+
{
|
|
17
|
+
routePath: deps.routePath,
|
|
18
|
+
dispatchPayload: deps.ingress.dispatchTurnEvent,
|
|
19
|
+
onDispatchError: deps.onDispatchError,
|
|
20
|
+
allowOnlyLocal: true
|
|
21
|
+
}
|
|
22
|
+
]
|
|
15
23
|
});
|
|
16
24
|
}
|
|
25
|
+
export function createBridgeHttpServer(routes) {
|
|
26
|
+
return createJsonServer({ routes });
|
|
27
|
+
}
|
|
17
28
|
function createJsonServer(deps) {
|
|
18
29
|
return createServer(async (request, response) => {
|
|
19
|
-
|
|
30
|
+
const route = deps.routes.find((candidate) => candidate.routePath === request.url);
|
|
31
|
+
if (!route) {
|
|
20
32
|
response.statusCode = 404;
|
|
21
33
|
response.end("not found");
|
|
22
34
|
return;
|
|
23
35
|
}
|
|
24
|
-
if (
|
|
36
|
+
if (route.allowOnlyLocal && !isLocalRequest(request)) {
|
|
25
37
|
response.statusCode = 403;
|
|
26
38
|
response.end("forbidden");
|
|
27
39
|
return;
|
|
@@ -45,10 +57,10 @@ function createJsonServer(deps) {
|
|
|
45
57
|
return;
|
|
46
58
|
}
|
|
47
59
|
Promise.resolve()
|
|
48
|
-
.then(() =>
|
|
60
|
+
.then(() => route.dispatchPayload(payload))
|
|
49
61
|
.catch((error) => {
|
|
50
62
|
const normalized = error instanceof Error ? error : new Error(typeof error === "string" ? error : "dispatch failed");
|
|
51
|
-
|
|
63
|
+
route.onDispatchError?.(normalized, payload);
|
|
52
64
|
});
|
|
53
65
|
response.statusCode = 202;
|
|
54
66
|
response.end("accepted");
|