qq-codex-bridge 0.1.2 → 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.
Files changed (39) hide show
  1. package/.env.example +62 -0
  2. package/README.md +232 -287
  3. package/bin/chatgpt-desktop.js +2 -0
  4. package/bin/qq-codex-weixin-gateway.js +14 -0
  5. package/dist/apps/bridge-daemon/src/bootstrap.js +161 -31
  6. package/dist/apps/bridge-daemon/src/cli.js +5 -1
  7. package/dist/apps/bridge-daemon/src/config.js +168 -37
  8. package/dist/apps/bridge-daemon/src/http-server.js +23 -11
  9. package/dist/apps/bridge-daemon/src/main.js +163 -29
  10. package/dist/apps/bridge-daemon/src/thread-command-handler.js +320 -23
  11. package/dist/apps/chatgpt-desktop-cli/src/cli.js +191 -0
  12. package/dist/apps/weixin-gateway/src/cli.js +446 -0
  13. package/dist/apps/weixin-gateway/src/config.js +135 -0
  14. package/dist/apps/weixin-gateway/src/dev.js +2 -0
  15. package/dist/apps/weixin-gateway/src/message-store.js +50 -0
  16. package/dist/apps/weixin-gateway/src/server.js +216 -0
  17. package/dist/apps/weixin-gateway/src/state.js +163 -0
  18. package/dist/apps/weixin-gateway/src/weixin-client.js +520 -0
  19. package/dist/packages/adapters/chatgpt-desktop/src/ax-client.js +472 -0
  20. package/dist/packages/adapters/chatgpt-desktop/src/bridge-provider.js +82 -0
  21. package/dist/packages/adapters/chatgpt-desktop/src/driver.js +161 -0
  22. package/dist/packages/adapters/chatgpt-desktop/src/image-cache.js +155 -0
  23. package/dist/packages/adapters/chatgpt-desktop/src/session-registry.js +48 -0
  24. package/dist/packages/adapters/chatgpt-desktop/src/types.js +1 -0
  25. package/dist/packages/adapters/codex-desktop/src/codex-app-server-driver.js +810 -0
  26. package/dist/packages/adapters/codex-desktop/src/codex-app-ui-notification-forwarder.js +33 -0
  27. package/dist/packages/adapters/codex-desktop/src/codex-desktop-driver.js +727 -123
  28. package/dist/packages/adapters/codex-desktop/src/codex-local-rollout-reader.js +227 -0
  29. package/dist/packages/adapters/codex-desktop/src/codex-local-submission-reader.js +142 -0
  30. package/dist/packages/adapters/weixin/src/weixin-channel-adapter.js +15 -0
  31. package/dist/packages/adapters/weixin/src/weixin-http-client.js +42 -0
  32. package/dist/packages/adapters/weixin/src/weixin-sender.js +200 -0
  33. package/dist/packages/adapters/weixin/src/weixin-webhook.js +35 -0
  34. package/dist/packages/orchestrator/src/bridge-orchestrator.js +72 -25
  35. package/dist/packages/orchestrator/src/weixin-outbound-format.js +55 -0
  36. package/dist/packages/ports/src/chat.js +1 -0
  37. package/dist/packages/store/src/session-repo.js +16 -3
  38. package/dist/packages/store/src/sqlite.js +3 -0
  39. 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 qqApiClient = new QqApiClient(config.qqBot.appId, config.qqBot.clientSecret, {
24
- markdownSupport: config.qqBot.markdownSupport
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 accountKey = "qqbot:default";
27
- const qqGatewaySessionStore = new FileQqGatewaySessionStore(path.join(path.dirname(config.databasePath), "qq-gateway-session.json"), accountKey, config.qqBot.appId);
28
- const adapters = {
29
- qq: createQqChannelAdapter({
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
- appId: config.qqBot.appId,
32
- apiClient: qqApiClient,
33
- sessionStore: qqGatewaySessionStore,
34
- mediaDownloadDir: path.join(path.dirname(config.databasePath), "media"),
35
- stt: config.qqBot.stt
36
- }),
37
- codexDesktop: new CodexDesktopDriver(new CdpSession({
38
- appName: config.codexDesktop.appName,
39
- remoteDebuggingPort: config.codexDesktop.remoteDebuggingPort
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
- const conversationProvider = {
43
- runTurn: async (message, options) => {
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
- if (session?.codexThreadRef !== binding.codexThreadRef) {
65
- await sessionStore.updateBinding(message.sessionKey, binding.codexThreadRef);
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
- await sessionStore.updateSkillContextKey(message.sessionKey, skillContextKey);
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(binding, {
131
+ const drafts = await adapters.codexDesktop.collectAssistantReply(stableBinding, {
71
132
  onDraft: options?.onDraft
72
133
  ? async (draft) => {
73
- await options.onDraft(formatQqOutboundDraft(enrichQqOutboundDraft({
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) => formatQqOutboundDraft(enrichQqOutboundDraft({
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 orchestrator = new BridgeOrchestrator({
167
+ const createChannelOrchestrator = (egress, draftFormatter) => new BridgeOrchestrator({
96
168
  sessionStore,
97
169
  transcriptStore,
98
170
  conversationProvider,
99
- qqEgress: adapters.qq.egress
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
- qqGatewaySessionStore
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: z.object({
10
- appId: z.string().min(1),
11
- clientSecret: z.string().min(1),
12
- markdownSupport: z.boolean(),
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
- appId: env.QQBOT_APP_ID,
53
- clientSecret: env.QQBOT_CLIENT_SECRET,
54
- markdownSupport: env.QQBOT_MARKDOWN_SUPPORT === "true",
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
- routePath: deps.webhookPath,
5
- dispatchPayload: deps.ingress.dispatchPayload,
6
- onDispatchError: deps.onDispatchError
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
- routePath: deps.routePath,
12
- dispatchPayload: deps.ingress.dispatchTurnEvent,
13
- onDispatchError: deps.onDispatchError,
14
- allowOnlyLocal: true
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
- if (request.url !== deps.routePath) {
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 (deps.allowOnlyLocal && !isLocalRequest(request)) {
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(() => deps.dispatchPayload(payload))
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
- deps.onDispatchError?.(normalized, payload);
63
+ route.onDispatchError?.(normalized, payload);
52
64
  });
53
65
  response.statusCode = 202;
54
66
  response.end("accepted");