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,446 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { ZodError } from "zod";
|
|
6
|
+
import { loadWeixinGatewayConfigFromEnv } from "./config.js";
|
|
7
|
+
import { WeixinGatewayMessageStore } from "./message-store.js";
|
|
8
|
+
import { createWeixinGatewayServer } from "./server.js";
|
|
9
|
+
import { forwardWeixinInboundToBridge, runWeixinLoginFlow as runWeixinLoginFlowImpl, WeixinClient } from "./weixin-client.js";
|
|
10
|
+
import { WeixinGatewayStateStore } from "./state.js";
|
|
11
|
+
export async function runCli(rawArgs, deps = {}) {
|
|
12
|
+
const args = rawArgs.filter((arg) => arg.length > 0 && arg !== "--");
|
|
13
|
+
const cwd = deps.cwd ?? process.cwd();
|
|
14
|
+
const env = deps.env ?? process.env;
|
|
15
|
+
const packageRoot = deps.packageRoot ?? findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
|
|
16
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
17
|
+
const writeStdout = deps.writeStdout ?? ((line) => console.log(line));
|
|
18
|
+
const writeStderr = deps.writeStderr ?? ((line) => console.error(line));
|
|
19
|
+
const parsedArgs = parseCliArgs(args);
|
|
20
|
+
if (!parsedArgs) {
|
|
21
|
+
writeStderr(`[qq-codex-weixin-gateway] 未知命令:${args.join(" ")}`);
|
|
22
|
+
printHelp(writeStdout);
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
|
25
|
+
if (parsedArgs.command === "init") {
|
|
26
|
+
return initEnvTemplate({ cwd, packageRoot, writeStdout, writeStderr });
|
|
27
|
+
}
|
|
28
|
+
if (parsedArgs.command === "help") {
|
|
29
|
+
printHelp(writeStdout);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
32
|
+
const envFilePath = path.join(cwd, ".env");
|
|
33
|
+
if (fs.existsSync(envFilePath)) {
|
|
34
|
+
const loadEnvFile = deps.loadEnvFile ?? process.loadEnvFile.bind(process);
|
|
35
|
+
loadEnvFile(envFilePath);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const config = loadWeixinGatewayConfigFromEnv(env);
|
|
39
|
+
const stateStore = deps.createStateStore?.(config.stateFilePath)
|
|
40
|
+
?? new WeixinGatewayStateStore(config.stateFilePath);
|
|
41
|
+
const runWeixinLoginFlow = deps.runWeixinLoginFlow ?? runWeixinLoginFlowImpl;
|
|
42
|
+
const selectedAccountId = parsedArgs.accountId ?? config.accountId;
|
|
43
|
+
if (parsedArgs.command === "login") {
|
|
44
|
+
const result = await runWeixinLoginFlow({
|
|
45
|
+
accountId: selectedAccountId,
|
|
46
|
+
force: parsedArgs.forceLogin,
|
|
47
|
+
onQrCode: (url) => {
|
|
48
|
+
writeStdout(`[qq-codex-weixin-gateway] 二维码地址:${url}`);
|
|
49
|
+
writeStdout("[qq-codex-weixin-gateway] 请在浏览器打开二维码地址并使用微信扫码确认。");
|
|
50
|
+
},
|
|
51
|
+
config,
|
|
52
|
+
stateStore: stateStore,
|
|
53
|
+
fetchFn
|
|
54
|
+
});
|
|
55
|
+
if (result.qrcodeUrl) {
|
|
56
|
+
writeStdout(`[qq-codex-weixin-gateway] 微信扫码登录成功,accountId=${result.accountId}`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
writeStdout(`[qq-codex-weixin-gateway] 账号 ${result.accountId} 已存在可用登录态,baseUrl=${result.baseUrl}`);
|
|
60
|
+
}
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
if (parsedArgs.command === "logout") {
|
|
64
|
+
stateStore.clearStoredAccount(selectedAccountId);
|
|
65
|
+
writeStdout(`[qq-codex-weixin-gateway] 已清理账号 ${selectedAccountId} 的登录态`);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
const service = await startWeixinGatewayService({
|
|
69
|
+
cwd,
|
|
70
|
+
env,
|
|
71
|
+
packageRoot,
|
|
72
|
+
fetchFn,
|
|
73
|
+
loadEnvFile: deps.loadEnvFile,
|
|
74
|
+
createMessageStore: deps.createMessageStore,
|
|
75
|
+
createStateStore: deps.createStateStore,
|
|
76
|
+
createServer: deps.createServer,
|
|
77
|
+
createWeixinClient: deps.createWeixinClient,
|
|
78
|
+
watchStateFile: deps.watchStateFile,
|
|
79
|
+
writeStdout,
|
|
80
|
+
writeStderr
|
|
81
|
+
});
|
|
82
|
+
process.once("SIGINT", () => {
|
|
83
|
+
void service.shutdown().finally(() => {
|
|
84
|
+
process.exit(0);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
process.once("SIGTERM", () => {
|
|
88
|
+
void service.shutdown().finally(() => {
|
|
89
|
+
process.exit(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof ZodError) {
|
|
96
|
+
writeStderr(`[qq-codex-weixin-gateway] 配置无效:${error.issues.map((issue) => issue.message).join("; ")}`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
writeStderr(`[qq-codex-weixin-gateway] fatal: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
|
+
if (error instanceof Error && error.stack) {
|
|
101
|
+
writeStderr(` stack: ${error.stack}`);
|
|
102
|
+
}
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export async function startWeixinGatewayService(options = {}) {
|
|
107
|
+
const cwd = options.cwd ?? process.cwd();
|
|
108
|
+
const env = options.env ?? process.env;
|
|
109
|
+
const packageRoot = options.packageRoot ?? findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
|
|
110
|
+
const fetchFn = options.fetchFn ?? fetch;
|
|
111
|
+
const writeStdout = options.writeStdout ?? ((line) => console.log(line));
|
|
112
|
+
const writeStderr = options.writeStderr ?? ((line) => console.error(line));
|
|
113
|
+
const envFilePath = path.join(cwd, ".env");
|
|
114
|
+
if (fs.existsSync(envFilePath)) {
|
|
115
|
+
const loadEnvFile = options.loadEnvFile ?? process.loadEnvFile.bind(process);
|
|
116
|
+
loadEnvFile(envFilePath);
|
|
117
|
+
}
|
|
118
|
+
void packageRoot;
|
|
119
|
+
const config = loadWeixinGatewayConfigFromEnv(env);
|
|
120
|
+
const createMessageStore = options.createMessageStore
|
|
121
|
+
?? ((filePath, limit) => new WeixinGatewayMessageStore(filePath, limit));
|
|
122
|
+
const messageStores = Object.fromEntries(config.accounts.map((account) => [
|
|
123
|
+
account.accountId,
|
|
124
|
+
createMessageStore(account.messageStorePath, config.recentMessageLimit)
|
|
125
|
+
]));
|
|
126
|
+
const messageStore = {
|
|
127
|
+
append: (message) => {
|
|
128
|
+
const accountId = resolveOutboundAccountId({
|
|
129
|
+
accountId: message.accountId,
|
|
130
|
+
accountKey: message.accountKey
|
|
131
|
+
}, config.accountId);
|
|
132
|
+
const store = messageStores[accountId] ?? messageStores[config.accountId] ?? Object.values(messageStores)[0];
|
|
133
|
+
store?.append(message);
|
|
134
|
+
},
|
|
135
|
+
listRecent: () => Object.values(messageStores)
|
|
136
|
+
.flatMap((store) => store.listRecent())
|
|
137
|
+
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
|
|
138
|
+
.slice(0, config.recentMessageLimit)
|
|
139
|
+
};
|
|
140
|
+
const stateStore = options.createStateStore?.(config.stateFilePath)
|
|
141
|
+
?? new WeixinGatewayStateStore(config.stateFilePath);
|
|
142
|
+
const activeClients = new Map();
|
|
143
|
+
const activeClientKeys = new Map();
|
|
144
|
+
const closeActiveClient = async (accountId) => {
|
|
145
|
+
const activeClient = activeClients.get(accountId);
|
|
146
|
+
if (!activeClient) {
|
|
147
|
+
activeClientKeys.delete(accountId);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
activeClients.delete(accountId);
|
|
151
|
+
activeClientKeys.delete(accountId);
|
|
152
|
+
await activeClient.close();
|
|
153
|
+
};
|
|
154
|
+
const resolveRuntimeAccount = (account) => stateStore.resolveRuntimeAccount(account.accountId, {
|
|
155
|
+
token: account.token,
|
|
156
|
+
baseUrl: account.baseUrl
|
|
157
|
+
});
|
|
158
|
+
const createWeixinClient = options.createWeixinClient
|
|
159
|
+
?? ((clientOptions) => new WeixinClient({
|
|
160
|
+
...clientOptions,
|
|
161
|
+
stateStore: clientOptions.stateStore,
|
|
162
|
+
fetchFn: clientOptions.fetchFn
|
|
163
|
+
}));
|
|
164
|
+
const refreshWeixinClient = async (reason) => {
|
|
165
|
+
const expectedAccountIds = new Set(config.accounts.map((account) => account.accountId));
|
|
166
|
+
for (const account of config.accounts) {
|
|
167
|
+
const runtimeAccount = resolveRuntimeAccount(account);
|
|
168
|
+
const nextClientKey = runtimeAccount
|
|
169
|
+
? `${runtimeAccount.accountId}|${runtimeAccount.baseUrl}|${runtimeAccount.token}`
|
|
170
|
+
: "";
|
|
171
|
+
if (!runtimeAccount) {
|
|
172
|
+
if (activeClients.has(account.accountId)) {
|
|
173
|
+
writeStdout(`[qq-codex-weixin-gateway] 未找到微信登录态,已停用 long-poll client { accountId: ${account.accountId} }`);
|
|
174
|
+
await closeActiveClient(account.accountId);
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (activeClients.has(account.accountId) && activeClientKeys.get(account.accountId) === nextClientKey) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
await closeActiveClient(account.accountId);
|
|
182
|
+
const nextClient = createWeixinClient({
|
|
183
|
+
accountId: runtimeAccount.accountId,
|
|
184
|
+
baseUrl: runtimeAccount.baseUrl,
|
|
185
|
+
token: runtimeAccount.token,
|
|
186
|
+
longPollTimeoutMs: config.longPollTimeoutMs,
|
|
187
|
+
apiTimeoutMs: config.apiTimeoutMs,
|
|
188
|
+
stateStore,
|
|
189
|
+
fetchFn,
|
|
190
|
+
onInboundMessage: async (message) => {
|
|
191
|
+
await forwardWeixinInboundToBridge(fetchFn, {
|
|
192
|
+
bridgeBaseUrl: config.bridgeBaseUrl,
|
|
193
|
+
bridgeWebhookPath: account.bridgeWebhookPath,
|
|
194
|
+
accountKey: `weixin:${account.accountId}`
|
|
195
|
+
}, message);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
activeClients.set(account.accountId, nextClient);
|
|
199
|
+
activeClientKeys.set(account.accountId, nextClientKey);
|
|
200
|
+
void nextClient.connect();
|
|
201
|
+
writeStdout(`[qq-codex-weixin-gateway] 微信 client 已连接 { reason: ${reason}, accountId: ${runtimeAccount.accountId}, baseUrl: ${runtimeAccount.baseUrl} }`);
|
|
202
|
+
}
|
|
203
|
+
for (const accountId of [...activeClients.keys()]) {
|
|
204
|
+
if (!expectedAccountIds.has(accountId)) {
|
|
205
|
+
await closeActiveClient(accountId);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
const outboundSender = {
|
|
210
|
+
sendTextMessage: async ({ accountKey, accountId, peerId, chatType, text, replyToMessageId }) => {
|
|
211
|
+
const targetAccountId = resolveOutboundAccountId({ accountKey, accountId }, config.accountId);
|
|
212
|
+
const activeClient = activeClients.get(targetAccountId);
|
|
213
|
+
if (!activeClient) {
|
|
214
|
+
throw new Error(`weixin gateway has no active logged-in client for account ${targetAccountId}`);
|
|
215
|
+
}
|
|
216
|
+
const contextToken = stateStore.getContextToken(activeClient.accountId, peerId);
|
|
217
|
+
await activeClient.sendTextMessage(peerId, text, contextToken || null);
|
|
218
|
+
console.log("[weixin-gateway] delivered outbound message", {
|
|
219
|
+
accountId: targetAccountId,
|
|
220
|
+
peerId,
|
|
221
|
+
chatType,
|
|
222
|
+
hasContextToken: Boolean(contextToken),
|
|
223
|
+
replyToMessageId
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
sendMessage: async ({ accountKey, accountId, peerId, chatType, content, mediaArtifacts, replyToMessageId }) => {
|
|
227
|
+
const targetAccountId = resolveOutboundAccountId({ accountKey, accountId }, config.accountId);
|
|
228
|
+
const activeClient = activeClients.get(targetAccountId);
|
|
229
|
+
if (!activeClient) {
|
|
230
|
+
throw new Error(`weixin gateway has no active logged-in client for account ${targetAccountId}`);
|
|
231
|
+
}
|
|
232
|
+
const contextToken = stateStore.getContextToken(activeClient.accountId, peerId);
|
|
233
|
+
await activeClient.sendMessage({
|
|
234
|
+
peerId,
|
|
235
|
+
chatType,
|
|
236
|
+
...(content ? { content } : {}),
|
|
237
|
+
...(mediaArtifacts?.length ? { mediaArtifacts } : {}),
|
|
238
|
+
contextToken: contextToken || null
|
|
239
|
+
});
|
|
240
|
+
console.log("[weixin-gateway] delivered outbound message", {
|
|
241
|
+
accountId: targetAccountId,
|
|
242
|
+
peerId,
|
|
243
|
+
chatType,
|
|
244
|
+
hasContextToken: Boolean(contextToken),
|
|
245
|
+
replyToMessageId,
|
|
246
|
+
mediaCount: mediaArtifacts?.length ?? 0
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
const server = options.createServer?.({
|
|
251
|
+
config,
|
|
252
|
+
messageStore,
|
|
253
|
+
fetchFn,
|
|
254
|
+
outboundSender
|
|
255
|
+
})
|
|
256
|
+
?? createWeixinGatewayServer({
|
|
257
|
+
config,
|
|
258
|
+
messageStore,
|
|
259
|
+
fetchFn,
|
|
260
|
+
outboundSender
|
|
261
|
+
});
|
|
262
|
+
await new Promise((resolve, reject) => {
|
|
263
|
+
server.once("error", reject);
|
|
264
|
+
server.listen(config.listenPort, config.listenHost, () => {
|
|
265
|
+
server.off("error", reject);
|
|
266
|
+
resolve();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
await refreshWeixinClient("startup");
|
|
270
|
+
const shouldWatchStateFile = options.watchStateFile !== false;
|
|
271
|
+
if (shouldWatchStateFile) {
|
|
272
|
+
fs.watchFile(config.stateFilePath, { interval: config.stateWatchIntervalMs }, async (current, previous) => {
|
|
273
|
+
if (current.mtimeMs === previous.mtimeMs) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
stateStore.reload();
|
|
278
|
+
await refreshWeixinClient("state-file-change");
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
writeStderr(`[qq-codex-weixin-gateway] 刷新微信状态失败:${error instanceof Error ? error.message : String(error)}`);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
writeStdout(`[qq-codex-weixin-gateway] ready { listenHost: ${config.listenHost}, listenPort: ${config.listenPort}, accounts: ${config.accounts.map((account) => account.accountId).join(",")}, loggedIn: ${activeClients.size} }`);
|
|
286
|
+
return {
|
|
287
|
+
shutdown: async () => {
|
|
288
|
+
fs.unwatchFile(config.stateFilePath);
|
|
289
|
+
await Promise.all([...activeClients.keys()].map((accountId) => closeActiveClient(accountId)));
|
|
290
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
291
|
+
},
|
|
292
|
+
status: {
|
|
293
|
+
channel: "weixin",
|
|
294
|
+
enabled: config.enabled,
|
|
295
|
+
listenHost: config.listenHost,
|
|
296
|
+
listenPort: config.listenPort,
|
|
297
|
+
loggedIn: activeClients.size > 0,
|
|
298
|
+
accountId: config.accountId,
|
|
299
|
+
accountIds: config.accounts.map((account) => account.accountId),
|
|
300
|
+
loggedInAccountIds: [...activeClients.keys()]
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
export async function runCliFromProcess() {
|
|
305
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
|
306
|
+
}
|
|
307
|
+
function initEnvTemplate(options) {
|
|
308
|
+
const targetPath = path.join(options.cwd, ".env.weixin-gateway");
|
|
309
|
+
if (fs.existsSync(targetPath)) {
|
|
310
|
+
options.writeStderr(`[qq-codex-weixin-gateway] 配置文件已存在:${targetPath}`);
|
|
311
|
+
return 1;
|
|
312
|
+
}
|
|
313
|
+
const template = [
|
|
314
|
+
"WEIXIN_ENABLED=true",
|
|
315
|
+
"WEIXIN_ACCOUNT_ID=default",
|
|
316
|
+
"WEIXIN_BASE_URL=https://ilinkai.weixin.qq.com",
|
|
317
|
+
"# WEIXIN_TOKEN=",
|
|
318
|
+
"WEIXIN_LONG_POLL_TIMEOUT_MS=35000",
|
|
319
|
+
"WEIXIN_API_TIMEOUT_MS=15000",
|
|
320
|
+
"WEIXIN_GATEWAY_STATE_FILE_PATH=runtime/weixin-gateway-state.json",
|
|
321
|
+
"WEIXIN_LOGIN_BASE_URL=https://ilinkai.weixin.qq.com",
|
|
322
|
+
"WEIXIN_BOT_TYPE=3",
|
|
323
|
+
"WEIXIN_QR_FETCH_TIMEOUT_MS=10000",
|
|
324
|
+
"WEIXIN_QR_POLL_TIMEOUT_MS=35000",
|
|
325
|
+
"WEIXIN_QR_TOTAL_TIMEOUT_MS=480000",
|
|
326
|
+
"WEIXIN_GATEWAY_STATE_WATCH_INTERVAL_MS=1000",
|
|
327
|
+
"WEIXIN_GATEWAY_LISTEN_HOST=127.0.0.1",
|
|
328
|
+
"WEIXIN_GATEWAY_LISTEN_PORT=3200",
|
|
329
|
+
"WEIXIN_GATEWAY_BRIDGE_BASE_URL=http://127.0.0.1:3100",
|
|
330
|
+
"WEIXIN_GATEWAY_BRIDGE_WEBHOOK_PATH=/webhooks/weixin",
|
|
331
|
+
"# WEIXIN_GATEWAY_EXPECTED_TOKEN=your-token",
|
|
332
|
+
"WEIXIN_GATEWAY_MESSAGE_STORE_PATH=runtime/weixin-gateway-messages.ndjson",
|
|
333
|
+
"WEIXIN_GATEWAY_RECENT_MESSAGE_LIMIT=100",
|
|
334
|
+
""
|
|
335
|
+
].join("\n");
|
|
336
|
+
fs.writeFileSync(targetPath, template, "utf8");
|
|
337
|
+
options.writeStdout(`[qq-codex-weixin-gateway] 已生成真实微信网关配置:${targetPath}`);
|
|
338
|
+
options.writeStdout("[qq-codex-weixin-gateway] 你也可以直接把这些变量写进项目根目录的 .env。");
|
|
339
|
+
return 0;
|
|
340
|
+
}
|
|
341
|
+
function resolveOutboundAccountId(target, fallbackAccountId) {
|
|
342
|
+
const explicitAccountId = String(target.accountId ?? "").trim();
|
|
343
|
+
if (explicitAccountId) {
|
|
344
|
+
return explicitAccountId;
|
|
345
|
+
}
|
|
346
|
+
const accountKey = String(target.accountKey ?? "").trim();
|
|
347
|
+
if (accountKey.startsWith("weixin:")) {
|
|
348
|
+
const accountId = accountKey.slice("weixin:".length).trim();
|
|
349
|
+
if (accountId) {
|
|
350
|
+
return accountId;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return fallbackAccountId;
|
|
354
|
+
}
|
|
355
|
+
function printHelp(writeStdout) {
|
|
356
|
+
writeStdout("qq-codex-weixin-gateway");
|
|
357
|
+
writeStdout("");
|
|
358
|
+
writeStdout("用法:");
|
|
359
|
+
writeStdout(" qq-codex-weixin-gateway 启动真实微信网关(long-poll + 本地转发)");
|
|
360
|
+
writeStdout(" qq-codex-weixin-gateway init 生成 .env.weixin-gateway 模板");
|
|
361
|
+
writeStdout(" qq-codex-weixin-gateway --weixin-login 发起微信扫码登录");
|
|
362
|
+
writeStdout(" qq-codex-weixin-gateway --weixin-login-force 强制重新扫码登录");
|
|
363
|
+
writeStdout(" qq-codex-weixin-gateway --weixin-logout 清理微信登录态");
|
|
364
|
+
writeStdout(" qq-codex-weixin-gateway help 查看帮助");
|
|
365
|
+
}
|
|
366
|
+
function findPackageRoot(startDir) {
|
|
367
|
+
let currentDir = startDir;
|
|
368
|
+
while (true) {
|
|
369
|
+
if (fs.existsSync(path.join(currentDir, "package.json"))) {
|
|
370
|
+
return currentDir;
|
|
371
|
+
}
|
|
372
|
+
const parentDir = path.dirname(currentDir);
|
|
373
|
+
if (parentDir === currentDir) {
|
|
374
|
+
throw new Error(`Unable to locate package root from ${startDir}`);
|
|
375
|
+
}
|
|
376
|
+
currentDir = parentDir;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function parseCliArgs(args) {
|
|
380
|
+
if (args.length === 0) {
|
|
381
|
+
return {
|
|
382
|
+
command: "serve",
|
|
383
|
+
forceLogin: false
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (args.length === 1 && (args[0] === "help" || args[0] === "-h" || args[0] === "--help")) {
|
|
387
|
+
return {
|
|
388
|
+
command: "help",
|
|
389
|
+
forceLogin: false
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
if (args.length === 1 && args[0] === "init") {
|
|
393
|
+
return {
|
|
394
|
+
command: "init",
|
|
395
|
+
forceLogin: false
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
let command = null;
|
|
399
|
+
let accountId;
|
|
400
|
+
let forceLogin = false;
|
|
401
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
402
|
+
const arg = args[index];
|
|
403
|
+
if (arg === "--weixin-login") {
|
|
404
|
+
command = "login";
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
if (arg === "--weixin-login-force") {
|
|
408
|
+
command = "login";
|
|
409
|
+
forceLogin = true;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (arg === "--weixin-logout") {
|
|
413
|
+
command = "logout";
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (arg === "--weixin-account") {
|
|
417
|
+
accountId = args[index + 1];
|
|
418
|
+
index += 1;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
if (!command) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
command,
|
|
428
|
+
accountId,
|
|
429
|
+
forceLogin
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const isEntrypoint = (() => {
|
|
433
|
+
const entrypoint = process.argv[1];
|
|
434
|
+
if (!entrypoint) {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
try {
|
|
438
|
+
return fileURLToPath(import.meta.url) === path.resolve(entrypoint);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
})();
|
|
444
|
+
if (isEntrypoint) {
|
|
445
|
+
void runCliFromProcess();
|
|
446
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const weixinGatewayAccountConfigSchema = z.object({
|
|
3
|
+
accountId: z.string().min(1),
|
|
4
|
+
bridgeWebhookPath: z.string().startsWith("/"),
|
|
5
|
+
baseUrl: z.string().url(),
|
|
6
|
+
token: z.string().min(1).nullable(),
|
|
7
|
+
messageStorePath: z.string().min(1)
|
|
8
|
+
});
|
|
9
|
+
export const weixinGatewayConfigSchema = z.object({
|
|
10
|
+
listenHost: z.string().min(1),
|
|
11
|
+
listenPort: z.number().int().positive(),
|
|
12
|
+
bridgeBaseUrl: z.string().url(),
|
|
13
|
+
bridgeWebhookPath: z.string().startsWith("/"),
|
|
14
|
+
expectedBearerToken: z.string().min(1).nullable(),
|
|
15
|
+
messageStorePath: z.string().min(1),
|
|
16
|
+
recentMessageLimit: z.number().int().positive(),
|
|
17
|
+
enabled: z.boolean(),
|
|
18
|
+
accountId: z.string().min(1),
|
|
19
|
+
baseUrl: z.string().url(),
|
|
20
|
+
token: z.string().min(1).nullable(),
|
|
21
|
+
longPollTimeoutMs: z.number().int().positive(),
|
|
22
|
+
apiTimeoutMs: z.number().int().positive(),
|
|
23
|
+
stateFilePath: z.string().min(1),
|
|
24
|
+
loginBaseUrl: z.string().url(),
|
|
25
|
+
loginBotType: z.string().min(1),
|
|
26
|
+
qrFetchTimeoutMs: z.number().int().positive(),
|
|
27
|
+
qrPollTimeoutMs: z.number().int().positive(),
|
|
28
|
+
qrTotalTimeoutMs: z.number().int().positive(),
|
|
29
|
+
stateWatchIntervalMs: z.number().int().positive(),
|
|
30
|
+
accounts: z.array(weixinGatewayAccountConfigSchema).min(1)
|
|
31
|
+
});
|
|
32
|
+
export function loadWeixinGatewayConfigFromEnv(env) {
|
|
33
|
+
const fallbackAccount = {
|
|
34
|
+
accountId: env.WEIXIN_ACCOUNT_ID ?? "default",
|
|
35
|
+
bridgeWebhookPath: env.WEIXIN_GATEWAY_BRIDGE_WEBHOOK_PATH
|
|
36
|
+
?? env.WEIXIN_WEBHOOK_PATH
|
|
37
|
+
?? "/webhooks/weixin",
|
|
38
|
+
baseUrl: env.WEIXIN_BASE_URL ?? "https://ilinkai.weixin.qq.com",
|
|
39
|
+
token: env.WEIXIN_TOKEN ?? null,
|
|
40
|
+
messageStorePath: env.WEIXIN_GATEWAY_MESSAGE_STORE_PATH
|
|
41
|
+
?? "runtime/weixin-gateway-messages.ndjson"
|
|
42
|
+
};
|
|
43
|
+
return weixinGatewayConfigSchema.parse({
|
|
44
|
+
listenHost: env.WEIXIN_GATEWAY_LISTEN_HOST ?? "127.0.0.1",
|
|
45
|
+
listenPort: Number(env.WEIXIN_GATEWAY_LISTEN_PORT ?? "3200"),
|
|
46
|
+
bridgeBaseUrl: env.WEIXIN_GATEWAY_BRIDGE_BASE_URL
|
|
47
|
+
?? `http://${env.QQ_CODEX_LISTEN_HOST ?? "127.0.0.1"}:${env.QQ_CODEX_LISTEN_PORT ?? "3100"}`,
|
|
48
|
+
bridgeWebhookPath: env.WEIXIN_GATEWAY_BRIDGE_WEBHOOK_PATH
|
|
49
|
+
?? env.WEIXIN_WEBHOOK_PATH
|
|
50
|
+
?? "/webhooks/weixin",
|
|
51
|
+
expectedBearerToken: env.WEIXIN_GATEWAY_EXPECTED_TOKEN ?? env.WEIXIN_EGRESS_TOKEN ?? null,
|
|
52
|
+
messageStorePath: env.WEIXIN_GATEWAY_MESSAGE_STORE_PATH
|
|
53
|
+
?? "runtime/weixin-gateway-messages.ndjson",
|
|
54
|
+
recentMessageLimit: Number(env.WEIXIN_GATEWAY_RECENT_MESSAGE_LIMIT ?? "100"),
|
|
55
|
+
enabled: env.WEIXIN_ENABLED !== "false",
|
|
56
|
+
accountId: env.WEIXIN_ACCOUNT_ID ?? "default",
|
|
57
|
+
baseUrl: env.WEIXIN_BASE_URL ?? "https://ilinkai.weixin.qq.com",
|
|
58
|
+
token: env.WEIXIN_TOKEN ?? null,
|
|
59
|
+
longPollTimeoutMs: Number(env.WEIXIN_LONG_POLL_TIMEOUT_MS ?? "35000"),
|
|
60
|
+
apiTimeoutMs: Number(env.WEIXIN_API_TIMEOUT_MS ?? "15000"),
|
|
61
|
+
stateFilePath: env.WEIXIN_GATEWAY_STATE_FILE_PATH ?? "runtime/weixin-gateway-state.json",
|
|
62
|
+
loginBaseUrl: env.WEIXIN_LOGIN_BASE_URL ?? "https://ilinkai.weixin.qq.com",
|
|
63
|
+
loginBotType: env.WEIXIN_BOT_TYPE ?? "3",
|
|
64
|
+
qrFetchTimeoutMs: Number(env.WEIXIN_QR_FETCH_TIMEOUT_MS ?? "10000"),
|
|
65
|
+
qrPollTimeoutMs: Number(env.WEIXIN_QR_POLL_TIMEOUT_MS ?? "35000"),
|
|
66
|
+
qrTotalTimeoutMs: Number(env.WEIXIN_QR_TOTAL_TIMEOUT_MS ?? String(8 * 60 * 1000)),
|
|
67
|
+
stateWatchIntervalMs: Number(env.WEIXIN_GATEWAY_STATE_WATCH_INTERVAL_MS ?? "1000"),
|
|
68
|
+
accounts: resolveGatewayAccounts(env, fallbackAccount)
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function resolveGatewayAccounts(env, fallback) {
|
|
72
|
+
const jsonAccounts = parseJsonArray(env.WEIXIN_GATEWAY_ACCOUNTS_JSON ?? env.WEIXIN_ACCOUNTS_JSON);
|
|
73
|
+
if (jsonAccounts) {
|
|
74
|
+
return jsonAccounts.map((item, index) => {
|
|
75
|
+
const record = asRecord(item);
|
|
76
|
+
const accountId = stringValue(record.accountId ?? record.id, `account${index + 1}`);
|
|
77
|
+
return {
|
|
78
|
+
accountId,
|
|
79
|
+
bridgeWebhookPath: stringValue(record.bridgeWebhookPath ?? record.webhookPath, `/webhooks/weixin/${accountId}`),
|
|
80
|
+
baseUrl: stringValue(record.baseUrl, fallback.baseUrl),
|
|
81
|
+
token: nullableString(record.token ?? record.weixinToken),
|
|
82
|
+
messageStorePath: stringValue(record.messageStorePath, `runtime/weixin-gateway-messages-${safePathSegment(accountId)}.ndjson`)
|
|
83
|
+
};
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const accountIds = splitList(env.WEIXIN_GATEWAY_ACCOUNT_IDS ?? env.WEIXIN_ACCOUNT_IDS);
|
|
87
|
+
if (accountIds.length > 0) {
|
|
88
|
+
return accountIds.map((accountId, index) => {
|
|
89
|
+
const suffix = envNameSuffix(accountId);
|
|
90
|
+
return {
|
|
91
|
+
accountId,
|
|
92
|
+
bridgeWebhookPath: env[`WEIXIN_GATEWAY_${suffix}_BRIDGE_WEBHOOK_PATH`]
|
|
93
|
+
?? env[`WEIXIN_${suffix}_WEBHOOK_PATH`]
|
|
94
|
+
?? `/webhooks/weixin/${accountId}`,
|
|
95
|
+
baseUrl: env[`WEIXIN_${suffix}_BASE_URL`] ?? fallback.baseUrl,
|
|
96
|
+
token: env[`WEIXIN_${suffix}_TOKEN`] ?? (index === 0 ? fallback.token : null),
|
|
97
|
+
messageStorePath: env[`WEIXIN_GATEWAY_${suffix}_MESSAGE_STORE_PATH`]
|
|
98
|
+
?? `runtime/weixin-gateway-messages-${safePathSegment(accountId)}.ndjson`
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return [fallback];
|
|
103
|
+
}
|
|
104
|
+
function parseJsonArray(value) {
|
|
105
|
+
if (!value?.trim()) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const parsed = JSON.parse(value);
|
|
109
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
110
|
+
}
|
|
111
|
+
function asRecord(value) {
|
|
112
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
113
|
+
? value
|
|
114
|
+
: {};
|
|
115
|
+
}
|
|
116
|
+
function stringValue(value, fallback) {
|
|
117
|
+
const text = String(value ?? "").trim();
|
|
118
|
+
return text || fallback;
|
|
119
|
+
}
|
|
120
|
+
function nullableString(value) {
|
|
121
|
+
const text = String(value ?? "").trim();
|
|
122
|
+
return text || null;
|
|
123
|
+
}
|
|
124
|
+
function splitList(value) {
|
|
125
|
+
return (value ?? "")
|
|
126
|
+
.split(",")
|
|
127
|
+
.map((item) => item.trim())
|
|
128
|
+
.filter(Boolean);
|
|
129
|
+
}
|
|
130
|
+
function envNameSuffix(value) {
|
|
131
|
+
return value.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase();
|
|
132
|
+
}
|
|
133
|
+
function safePathSegment(value) {
|
|
134
|
+
return value.replace(/[^a-zA-Z0-9._-]/g, "_") || "default";
|
|
135
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export class WeixinGatewayMessageStore {
|
|
4
|
+
filePath;
|
|
5
|
+
limit;
|
|
6
|
+
messages = [];
|
|
7
|
+
constructor(filePath, limit) {
|
|
8
|
+
this.filePath = filePath;
|
|
9
|
+
this.limit = limit;
|
|
10
|
+
this.loadFromDisk();
|
|
11
|
+
}
|
|
12
|
+
append(message) {
|
|
13
|
+
this.messages.push(message);
|
|
14
|
+
this.trimToLimit();
|
|
15
|
+
this.persistLine(message);
|
|
16
|
+
}
|
|
17
|
+
listRecent() {
|
|
18
|
+
return [...this.messages].reverse();
|
|
19
|
+
}
|
|
20
|
+
loadFromDisk() {
|
|
21
|
+
if (!fs.existsSync(this.filePath)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const content = fs.readFileSync(this.filePath, "utf8");
|
|
25
|
+
for (const line of content.split("\n")) {
|
|
26
|
+
const trimmed = line.trim();
|
|
27
|
+
if (!trimmed) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(trimmed);
|
|
32
|
+
this.messages.push(parsed);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// ignore malformed historical lines
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
this.trimToLimit();
|
|
39
|
+
}
|
|
40
|
+
persistLine(message) {
|
|
41
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
42
|
+
fs.appendFileSync(this.filePath, `${JSON.stringify(message)}\n`, "utf8");
|
|
43
|
+
}
|
|
44
|
+
trimToLimit() {
|
|
45
|
+
if (this.messages.length <= this.limit) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.messages.splice(0, this.messages.length - this.limit);
|
|
49
|
+
}
|
|
50
|
+
}
|