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.
- 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 +320 -23
- 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,191 @@
|
|
|
1
|
+
import { ChatgptDesktopDriver } from "../../../packages/adapters/chatgpt-desktop/src/driver.js";
|
|
2
|
+
function printJson(data) {
|
|
3
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
4
|
+
}
|
|
5
|
+
function printError(msg) {
|
|
6
|
+
process.stderr.write(msg + "\n");
|
|
7
|
+
}
|
|
8
|
+
const BOOL_FLAGS = new Set(["json", "help"]);
|
|
9
|
+
function parseArgs(argv) {
|
|
10
|
+
const [, , command = "help", ...rest] = argv;
|
|
11
|
+
const args = [];
|
|
12
|
+
const flags = {};
|
|
13
|
+
for (let i = 0; i < rest.length; i++) {
|
|
14
|
+
const tok = rest[i];
|
|
15
|
+
if (tok.startsWith("--")) {
|
|
16
|
+
const key = tok.slice(2);
|
|
17
|
+
if (BOOL_FLAGS.has(key)) {
|
|
18
|
+
flags[key] = true;
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const next = rest[i + 1];
|
|
22
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
23
|
+
flags[key] = next;
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
flags[key] = true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
args.push(tok);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { command, args, flags };
|
|
36
|
+
}
|
|
37
|
+
async function cmdHealth(driver, flags) {
|
|
38
|
+
const result = await driver.health();
|
|
39
|
+
if (flags["json"]) {
|
|
40
|
+
printJson(result);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
console.log(`App running: ${result.appRunning}`);
|
|
44
|
+
console.log(`Accessibility: ${result.accessibility}`);
|
|
45
|
+
console.log(`Cache dir: ${result.cacheDirFound}`);
|
|
46
|
+
console.log(`Frontmost: ${result.frontmost}`);
|
|
47
|
+
console.log(`Overall OK: ${result.ok}`);
|
|
48
|
+
}
|
|
49
|
+
process.exit(result.ok ? 0 : 1);
|
|
50
|
+
}
|
|
51
|
+
async function cmdAsk(driver, args, flags) {
|
|
52
|
+
const prompt = args[0];
|
|
53
|
+
if (!prompt) {
|
|
54
|
+
printError("Usage: chatgpt-desktop ask [--json] [--session <key>] <prompt>");
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
const input = {
|
|
58
|
+
mode: "text",
|
|
59
|
+
prompt,
|
|
60
|
+
sessionKey: typeof flags["session"] === "string" ? flags["session"] : undefined,
|
|
61
|
+
timeoutMs: typeof flags["timeout"] === "string" ? Number(flags["timeout"]) : undefined
|
|
62
|
+
};
|
|
63
|
+
const result = await driver.run(input);
|
|
64
|
+
if (flags["json"]) {
|
|
65
|
+
printJson(result);
|
|
66
|
+
}
|
|
67
|
+
else if (result.ok) {
|
|
68
|
+
console.log(result.text);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
printError(`Error [${result.errorCode}]: ${result.message}`);
|
|
72
|
+
}
|
|
73
|
+
process.exit(result.ok ? 0 : 1);
|
|
74
|
+
}
|
|
75
|
+
async function cmdImage(driver, args, flags) {
|
|
76
|
+
const prompt = args[0];
|
|
77
|
+
if (!prompt) {
|
|
78
|
+
printError("Usage: chatgpt-desktop image [--json] [--out-dir <dir>] [--session <key>] <prompt>");
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
const outDir = typeof flags["out-dir"] === "string" ? flags["out-dir"] : undefined;
|
|
82
|
+
const input = {
|
|
83
|
+
mode: "image",
|
|
84
|
+
prompt,
|
|
85
|
+
sessionKey: typeof flags["session"] === "string" ? flags["session"] : undefined,
|
|
86
|
+
timeoutMs: typeof flags["timeout"] === "string" ? Number(flags["timeout"]) : undefined
|
|
87
|
+
};
|
|
88
|
+
const result = await driver.run(input);
|
|
89
|
+
if (flags["json"]) {
|
|
90
|
+
printJson(result);
|
|
91
|
+
}
|
|
92
|
+
else if (result.ok) {
|
|
93
|
+
for (const m of result.media) {
|
|
94
|
+
console.log(m.localPath);
|
|
95
|
+
}
|
|
96
|
+
if (result.text)
|
|
97
|
+
console.log(result.text);
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
printError(`Error [${result.errorCode}]: ${result.message}`);
|
|
101
|
+
}
|
|
102
|
+
void outDir; // outDir is passed via driver opts at construction time; noted for future use
|
|
103
|
+
process.exit(result.ok ? 0 : 1);
|
|
104
|
+
}
|
|
105
|
+
async function cmdImages(driver, args, flags) {
|
|
106
|
+
const prompt = args[0];
|
|
107
|
+
if (!prompt) {
|
|
108
|
+
printError("Usage: chatgpt-desktop images [--json] [--count <n>] [--out-dir <dir>] <prompt>");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
const count = typeof flags["count"] === "string" ? Math.max(1, parseInt(flags["count"], 10)) : 1;
|
|
112
|
+
const results = [];
|
|
113
|
+
let anyFailed = false;
|
|
114
|
+
for (let i = 0; i < count; i++) {
|
|
115
|
+
const input = {
|
|
116
|
+
mode: "image",
|
|
117
|
+
prompt: `${prompt} (第 ${i + 1} 张,共 ${count} 张)`,
|
|
118
|
+
sessionKey: typeof flags["session"] === "string" ? flags["session"] : undefined,
|
|
119
|
+
timeoutMs: typeof flags["timeout"] === "string" ? Number(flags["timeout"]) : undefined
|
|
120
|
+
};
|
|
121
|
+
const result = await driver.run(input);
|
|
122
|
+
results.push(result);
|
|
123
|
+
if (!result.ok)
|
|
124
|
+
anyFailed = true;
|
|
125
|
+
}
|
|
126
|
+
if (flags["json"]) {
|
|
127
|
+
printJson(results);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
for (const r of results) {
|
|
131
|
+
if (r.ok) {
|
|
132
|
+
for (const m of r.media)
|
|
133
|
+
console.log(m.localPath);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
printError(`Error [${r.errorCode}]: ${r.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
process.exit(anyFailed ? 1 : 0);
|
|
141
|
+
}
|
|
142
|
+
function printHelp() {
|
|
143
|
+
console.log(`chatgpt-desktop <command> [options]
|
|
144
|
+
|
|
145
|
+
Commands:
|
|
146
|
+
health Check App, accessibility and cache dir
|
|
147
|
+
ask <prompt> Send a text message and print reply
|
|
148
|
+
image <prompt> Generate an image and print local path
|
|
149
|
+
images --count N <prompt> Generate N images serially
|
|
150
|
+
|
|
151
|
+
Options:
|
|
152
|
+
--json Output JSON
|
|
153
|
+
--session <key> Session key for conversation continuity
|
|
154
|
+
--out-dir <dir> Output directory for images
|
|
155
|
+
--timeout <ms> Override reply timeout in milliseconds
|
|
156
|
+
--count <n> Number of images (images command only)
|
|
157
|
+
`);
|
|
158
|
+
}
|
|
159
|
+
async function main() {
|
|
160
|
+
const { command, args, flags } = parseArgs(process.argv);
|
|
161
|
+
const outDir = typeof flags["out-dir"] === "string" ? flags["out-dir"] : undefined;
|
|
162
|
+
const driver = new ChatgptDesktopDriver({ destDir: outDir });
|
|
163
|
+
switch (command) {
|
|
164
|
+
case "health":
|
|
165
|
+
await cmdHealth(driver, flags);
|
|
166
|
+
break;
|
|
167
|
+
case "ask":
|
|
168
|
+
case "chat":
|
|
169
|
+
await cmdAsk(driver, args, flags);
|
|
170
|
+
break;
|
|
171
|
+
case "image":
|
|
172
|
+
await cmdImage(driver, args, flags);
|
|
173
|
+
break;
|
|
174
|
+
case "images":
|
|
175
|
+
await cmdImages(driver, args, flags);
|
|
176
|
+
break;
|
|
177
|
+
case "help":
|
|
178
|
+
case "--help":
|
|
179
|
+
case "-h":
|
|
180
|
+
printHelp();
|
|
181
|
+
break;
|
|
182
|
+
default:
|
|
183
|
+
printError(`Unknown command: ${command}`);
|
|
184
|
+
printHelp();
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
main().catch((err) => {
|
|
189
|
+
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
});
|
|
@@ -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
|
+
}
|