claude-codex-wechat 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +558 -0
- package/config.example.json +18 -0
- package/dist/server/cli.js +4130 -0
- package/dist/web/assets/bootstrap-icons-BeopsB42.woff +0 -0
- package/dist/web/assets/bootstrap-icons-mSm7cUeB.woff2 +0 -0
- package/dist/web/assets/index-CrRUXxi8.css +1 -0
- package/dist/web/assets/index-Dxzp2xaC.js +10 -0
- package/dist/web/index.html +13 -0
- package/package.json +54 -0
|
@@ -0,0 +1,4130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire as __cjsCreateRequire } from 'node:module'; const require = __cjsCreateRequire(import.meta.url);
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
|
|
6
|
+
import { dirname as dirname10, join as join10 } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
// src/daemon/bootstrap.ts
|
|
10
|
+
import { networkInterfaces } from "node:os";
|
|
11
|
+
|
|
12
|
+
// src/daemon/server.ts
|
|
13
|
+
import { mkdtempSync } from "node:fs";
|
|
14
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
15
|
+
import { dirname as dirname9, join as join8 } from "node:path";
|
|
16
|
+
import Fastify from "fastify";
|
|
17
|
+
|
|
18
|
+
// src/channels/platforms.ts
|
|
19
|
+
var PRIMARY_WEIXIN_PLATFORM = "weixin";
|
|
20
|
+
|
|
21
|
+
// src/channels/weixin-direct/loginClient.ts
|
|
22
|
+
var DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com";
|
|
23
|
+
var WeixinDirectLoginClient = class {
|
|
24
|
+
baseUrl;
|
|
25
|
+
fetchImpl;
|
|
26
|
+
pollIntervalMs;
|
|
27
|
+
constructor(input = {}) {
|
|
28
|
+
this.baseUrl = (input.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
29
|
+
this.fetchImpl = input.fetchImpl ?? fetch;
|
|
30
|
+
this.pollIntervalMs = input.pollIntervalMs ?? 2e3;
|
|
31
|
+
}
|
|
32
|
+
async fetchQrCode() {
|
|
33
|
+
const data = await this.getJson("ilink/bot/get_bot_qrcode?bot_type=3");
|
|
34
|
+
const ticket = data.qrcode?.trim();
|
|
35
|
+
const qrcodeData = data.qrcode_img_content?.trim();
|
|
36
|
+
if (!ticket || !qrcodeData) throw new Error("weixin_qrcode_missing_fields");
|
|
37
|
+
return { ticket, qrcodeData };
|
|
38
|
+
}
|
|
39
|
+
async pollQrCodeStatus(ticket) {
|
|
40
|
+
const data = await this.getJson(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(ticket)}`);
|
|
41
|
+
const status = data.status?.trim().toLowerCase() ?? "wait";
|
|
42
|
+
if (status === "scaned") return { status: "scanned" };
|
|
43
|
+
if (status === "confirmed") {
|
|
44
|
+
return {
|
|
45
|
+
status: "confirmed",
|
|
46
|
+
accountId: data.ilink_bot_id?.trim() ?? "",
|
|
47
|
+
botToken: data.bot_token?.trim() ?? "",
|
|
48
|
+
baseUrl: data.baseurl?.trim() || this.baseUrl
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (status === "expired") return { status: "expired" };
|
|
52
|
+
return { status: "waiting" };
|
|
53
|
+
}
|
|
54
|
+
async getJson(path) {
|
|
55
|
+
const response = await this.fetchImpl(`${this.baseUrl}/${path}`, {
|
|
56
|
+
headers: {
|
|
57
|
+
"iLink-App-ClientVersion": "1"
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
if (!response.ok) throw new Error(`weixin_login_request_failed:${response.status}`);
|
|
61
|
+
const payload = await response.json();
|
|
62
|
+
return payload.data ?? payload;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// src/daemon/config.ts
|
|
67
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
68
|
+
import { homedir } from "node:os";
|
|
69
|
+
import { join } from "node:path";
|
|
70
|
+
function defaultConfigPath() {
|
|
71
|
+
return join(homedir(), ".claude-codex-wechat", "config.json");
|
|
72
|
+
}
|
|
73
|
+
function loadBridgeConfig(path = process.env.BRIDGE_CONFIG ?? defaultConfigPath()) {
|
|
74
|
+
if (!existsSync(path)) return normalizeBridgeConfig({}, process.env, path);
|
|
75
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
76
|
+
return normalizeBridgeConfig(raw, process.env, path);
|
|
77
|
+
}
|
|
78
|
+
function normalizeBridgeConfig(raw, env, path) {
|
|
79
|
+
const record = raw && typeof raw === "object" ? raw : {};
|
|
80
|
+
return {
|
|
81
|
+
wechat: normalizeWechatConfig(record.wechat, env),
|
|
82
|
+
bridge: normalizeBridgeDefaultsConfig(record.bridge),
|
|
83
|
+
providers: normalizeProvidersConfig(record.providers, env)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function normalizeProvidersConfig(raw, env) {
|
|
87
|
+
const record = raw && typeof raw === "object" ? raw : {};
|
|
88
|
+
const claude = normalizeProviderCommand(record.claude, env.BRIDGE_CLAUDE_COMMAND);
|
|
89
|
+
const codex = normalizeProviderCommand(record.codex, env.BRIDGE_CODEX_COMMAND);
|
|
90
|
+
if (!claude && !codex) return void 0;
|
|
91
|
+
return {
|
|
92
|
+
...claude ? { claude } : {},
|
|
93
|
+
...codex ? { codex } : {}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function normalizeBridgeDefaultsConfig(raw) {
|
|
97
|
+
const record = raw && typeof raw === "object" ? raw : {};
|
|
98
|
+
const defaultProvider = record.defaultProvider === "codex" ? "codex" : record.defaultProvider === "claude-code" ? "claude-code" : void 0;
|
|
99
|
+
const defaultWorkspace = typeof record.defaultWorkspace === "string" && record.defaultWorkspace.trim() ? record.defaultWorkspace : void 0;
|
|
100
|
+
if (!defaultProvider && !defaultWorkspace) return void 0;
|
|
101
|
+
return {
|
|
102
|
+
...defaultProvider ? { defaultProvider } : {},
|
|
103
|
+
...defaultWorkspace ? { defaultWorkspace } : {}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function normalizeProviderCommand(raw, envFallback) {
|
|
107
|
+
const record = raw && typeof raw === "object" ? raw : {};
|
|
108
|
+
const command = typeof record.command === "string" && record.command ? record.command : typeof envFallback === "string" && envFallback ? envFallback : void 0;
|
|
109
|
+
return command ? { command } : void 0;
|
|
110
|
+
}
|
|
111
|
+
function normalizeWechatConfig(raw, env) {
|
|
112
|
+
const record = raw && typeof raw === "object" ? raw : {};
|
|
113
|
+
const enabled = record.enabled === true || env.BRIDGE_WECHAT_ENABLED === "1" || env.BRIDGE_WECHAT_ENABLED === "true";
|
|
114
|
+
const baseUrl = typeof record.baseUrl === "string" && record.baseUrl ? record.baseUrl : typeof env.BRIDGE_WECHAT_BASE_URL === "string" && env.BRIDGE_WECHAT_BASE_URL ? env.BRIDGE_WECHAT_BASE_URL : void 0;
|
|
115
|
+
const token = typeof record.token === "string" && record.token ? record.token : typeof env.BRIDGE_WECHAT_TOKEN === "string" && env.BRIDGE_WECHAT_TOKEN ? env.BRIDGE_WECHAT_TOKEN : void 0;
|
|
116
|
+
const accountId = typeof record.accountId === "string" && record.accountId ? record.accountId : typeof env.BRIDGE_WECHAT_ACCOUNT_ID === "string" && env.BRIDGE_WECHAT_ACCOUNT_ID ? env.BRIDGE_WECHAT_ACCOUNT_ID : void 0;
|
|
117
|
+
if (!enabled && !baseUrl && !token && !accountId) return void 0;
|
|
118
|
+
return {
|
|
119
|
+
enabled,
|
|
120
|
+
baseUrl,
|
|
121
|
+
token,
|
|
122
|
+
accountId
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/daemon/configPersistence.ts
|
|
127
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
128
|
+
import { dirname } from "node:path";
|
|
129
|
+
async function persistWechatCredentialsToConfigFile(input) {
|
|
130
|
+
const currentConfig = await readConfigFile(input.configPath);
|
|
131
|
+
const nextConfig = {
|
|
132
|
+
...currentConfig,
|
|
133
|
+
wechat: {
|
|
134
|
+
...isRecord(currentConfig.wechat) ? currentConfig.wechat : {},
|
|
135
|
+
enabled: true,
|
|
136
|
+
baseUrl: input.baseUrl,
|
|
137
|
+
token: input.token,
|
|
138
|
+
accountId: input.accountId
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
await mkdir(dirname(input.configPath), { recursive: true });
|
|
142
|
+
await writeFile(input.configPath, `${JSON.stringify(nextConfig, null, 2)}
|
|
143
|
+
`, "utf8");
|
|
144
|
+
}
|
|
145
|
+
async function deleteConfigFile(configPath) {
|
|
146
|
+
try {
|
|
147
|
+
await rm(configPath);
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (isMissingFileError(error)) return;
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async function persistBridgeDefaultsToConfigFile(input) {
|
|
154
|
+
const currentConfig = await readConfigFile(input.configPath);
|
|
155
|
+
const nextConfig = {
|
|
156
|
+
...currentConfig,
|
|
157
|
+
bridge: {
|
|
158
|
+
...isRecord(currentConfig.bridge) ? currentConfig.bridge : {},
|
|
159
|
+
defaultProvider: input.defaultProvider,
|
|
160
|
+
defaultWorkspace: input.defaultWorkspace
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
await mkdir(dirname(input.configPath), { recursive: true });
|
|
164
|
+
await writeFile(input.configPath, `${JSON.stringify(nextConfig, null, 2)}
|
|
165
|
+
`, "utf8");
|
|
166
|
+
}
|
|
167
|
+
async function persistProviderCommandsToConfigFile(input) {
|
|
168
|
+
const currentConfig = await readConfigFile(input.configPath);
|
|
169
|
+
const normalizedProviders = normalizeProvidersForPersistence(input.providers);
|
|
170
|
+
const nextConfig = {
|
|
171
|
+
...currentConfig,
|
|
172
|
+
providers: normalizedProviders
|
|
173
|
+
};
|
|
174
|
+
await mkdir(dirname(input.configPath), { recursive: true });
|
|
175
|
+
await writeFile(input.configPath, `${JSON.stringify(nextConfig, null, 2)}
|
|
176
|
+
`, "utf8");
|
|
177
|
+
}
|
|
178
|
+
async function readConfigFile(path) {
|
|
179
|
+
try {
|
|
180
|
+
const raw = await readFile(path, "utf8");
|
|
181
|
+
const parsed = JSON.parse(raw);
|
|
182
|
+
return isRecord(parsed) ? parsed : {};
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (isMissingFileError(error)) return {};
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function isRecord(value) {
|
|
189
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
190
|
+
}
|
|
191
|
+
function isMissingFileError(error) {
|
|
192
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
193
|
+
}
|
|
194
|
+
function normalizeProvidersForPersistence(providers) {
|
|
195
|
+
const claude = providers?.claude?.command ? { command: providers.claude.command } : void 0;
|
|
196
|
+
const codex = providers?.codex?.command ? { command: providers.codex.command } : void 0;
|
|
197
|
+
if (!claude && !codex) return void 0;
|
|
198
|
+
return {
|
|
199
|
+
...claude ? { claude } : {},
|
|
200
|
+
...codex ? { codex } : {}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/channels/weixin-direct/weixinStateStore.ts
|
|
205
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
|
|
206
|
+
import { dirname as dirname2 } from "node:path";
|
|
207
|
+
var PUSH_QUOTA_LIMIT = 10;
|
|
208
|
+
var PUSH_WINDOW_MS = 24 * 60 * 60 * 1e3;
|
|
209
|
+
var FileWeixinStateStore = class {
|
|
210
|
+
constructor(configPath) {
|
|
211
|
+
this.configPath = configPath;
|
|
212
|
+
}
|
|
213
|
+
configPath;
|
|
214
|
+
load() {
|
|
215
|
+
const channel = this.readChannel();
|
|
216
|
+
const contextTokens = {};
|
|
217
|
+
for (const [userId, user] of Object.entries(channel.users ?? {})) {
|
|
218
|
+
if (user?.contextToken) contextTokens[userId] = user.contextToken;
|
|
219
|
+
}
|
|
220
|
+
return { contextTokens, cursor: channel.cursor ?? "" };
|
|
221
|
+
}
|
|
222
|
+
setContextToken(userId, token) {
|
|
223
|
+
if (!userId || !token) return;
|
|
224
|
+
const state = this.readState();
|
|
225
|
+
const channel = state.bridge?.weixinChannel ?? {};
|
|
226
|
+
const users = { ...channel.users ?? {} };
|
|
227
|
+
users[userId] = { contextToken: token, windowStartAt: Date.now(), sentCount: 0 };
|
|
228
|
+
this.writeChannel(state, { ...channel, users });
|
|
229
|
+
}
|
|
230
|
+
setCursor(cursor) {
|
|
231
|
+
const state = this.readState();
|
|
232
|
+
const channel = state.bridge?.weixinChannel ?? {};
|
|
233
|
+
if ((channel.cursor ?? "") === cursor) return;
|
|
234
|
+
this.writeChannel(state, { ...channel, cursor });
|
|
235
|
+
}
|
|
236
|
+
canSend(userId) {
|
|
237
|
+
const user = this.readChannel().users?.[userId];
|
|
238
|
+
if (!user?.contextToken) return false;
|
|
239
|
+
if (Date.now() - user.windowStartAt >= PUSH_WINDOW_MS) return false;
|
|
240
|
+
return user.sentCount < PUSH_QUOTA_LIMIT;
|
|
241
|
+
}
|
|
242
|
+
recordSent(userId) {
|
|
243
|
+
const state = this.readState();
|
|
244
|
+
const channel = state.bridge?.weixinChannel ?? {};
|
|
245
|
+
const user = channel.users?.[userId];
|
|
246
|
+
if (!user) return;
|
|
247
|
+
const users = { ...channel.users, [userId]: { ...user, sentCount: user.sentCount + 1 } };
|
|
248
|
+
this.writeChannel(state, { ...channel, users });
|
|
249
|
+
}
|
|
250
|
+
getQuota(userId) {
|
|
251
|
+
const user = this.readChannel().users?.[userId];
|
|
252
|
+
if (!user?.contextToken) return { remaining: 0, sentCount: 0, windowStartAt: 0, expired: true };
|
|
253
|
+
const expired = Date.now() - user.windowStartAt >= PUSH_WINDOW_MS;
|
|
254
|
+
const remaining = expired ? 0 : Math.max(0, PUSH_QUOTA_LIMIT - user.sentCount);
|
|
255
|
+
return { remaining, sentCount: user.sentCount, windowStartAt: user.windowStartAt, expired };
|
|
256
|
+
}
|
|
257
|
+
enqueueOutbound(chatId, item) {
|
|
258
|
+
if (!chatId) return;
|
|
259
|
+
const state = this.readState();
|
|
260
|
+
const channel = state.bridge?.weixinChannel ?? {};
|
|
261
|
+
const outbox = { ...channel.outbox ?? {} };
|
|
262
|
+
outbox[chatId] = [...outbox[chatId] ?? [], item];
|
|
263
|
+
this.writeChannel(state, { ...channel, outbox });
|
|
264
|
+
}
|
|
265
|
+
peekOutbound(chatId) {
|
|
266
|
+
return this.readChannel().outbox?.[chatId] ?? [];
|
|
267
|
+
}
|
|
268
|
+
shiftOutbound(chatId) {
|
|
269
|
+
const state = this.readState();
|
|
270
|
+
const channel = state.bridge?.weixinChannel ?? {};
|
|
271
|
+
const list = channel.outbox?.[chatId];
|
|
272
|
+
if (!list?.length) return;
|
|
273
|
+
const outbox = { ...channel.outbox };
|
|
274
|
+
const rest = list.slice(1);
|
|
275
|
+
if (rest.length) outbox[chatId] = rest;
|
|
276
|
+
else delete outbox[chatId];
|
|
277
|
+
this.writeChannel(state, { ...channel, outbox });
|
|
278
|
+
}
|
|
279
|
+
hasPendingOutbound(chatId) {
|
|
280
|
+
return (this.readChannel().outbox?.[chatId]?.length ?? 0) > 0;
|
|
281
|
+
}
|
|
282
|
+
clearOutbound(chatId) {
|
|
283
|
+
const state = this.readState();
|
|
284
|
+
const channel = state.bridge?.weixinChannel ?? {};
|
|
285
|
+
if (!channel.outbox?.[chatId]) return;
|
|
286
|
+
const outbox = { ...channel.outbox };
|
|
287
|
+
delete outbox[chatId];
|
|
288
|
+
this.writeChannel(state, { ...channel, outbox });
|
|
289
|
+
}
|
|
290
|
+
clear() {
|
|
291
|
+
const state = this.readState();
|
|
292
|
+
if (!state.bridge?.weixinChannel) return;
|
|
293
|
+
this.writeChannel(state, { cursor: "", users: {}, outbox: {} });
|
|
294
|
+
}
|
|
295
|
+
readChannel() {
|
|
296
|
+
return this.readState().bridge?.weixinChannel ?? {};
|
|
297
|
+
}
|
|
298
|
+
readState() {
|
|
299
|
+
if (!existsSync2(this.configPath)) return {};
|
|
300
|
+
try {
|
|
301
|
+
const raw = JSON.parse(readFileSync2(this.configPath, "utf8"));
|
|
302
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
303
|
+
} catch {
|
|
304
|
+
return {};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
writeChannel(state, channel) {
|
|
308
|
+
const next = {
|
|
309
|
+
...state,
|
|
310
|
+
bridge: { ...state.bridge ?? {}, weixinChannel: channel }
|
|
311
|
+
};
|
|
312
|
+
mkdirSync(dirname2(this.configPath), { recursive: true });
|
|
313
|
+
writeFileSync(this.configPath, `${JSON.stringify(next, null, 2)}
|
|
314
|
+
`, "utf8");
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// src/providers/claude-code/nativeSessions.ts
|
|
319
|
+
import { appendFile, mkdir as mkdir2, readdir, readFile as readFile2, stat, writeFile as writeFile2 } from "node:fs/promises";
|
|
320
|
+
import { homedir as homedir2 } from "node:os";
|
|
321
|
+
import { basename, dirname as dirname3, join as join2 } from "node:path";
|
|
322
|
+
|
|
323
|
+
// src/session/sessionBridgeTag.ts
|
|
324
|
+
function buildSessionBridgeName(input) {
|
|
325
|
+
const summary = input.summary?.trim();
|
|
326
|
+
return summary ? summary : void 0;
|
|
327
|
+
}
|
|
328
|
+
function parseSessionBridgeName(value) {
|
|
329
|
+
if (!value) return null;
|
|
330
|
+
const match = value.match(/\[claude-codex-wechat:([A-Za-z0-9_-]+)\]/);
|
|
331
|
+
if (!match?.[1]) return null;
|
|
332
|
+
try {
|
|
333
|
+
const decoded = JSON.parse(Buffer.from(match[1], "base64url").toString("utf8"));
|
|
334
|
+
if (decoded.platform !== "weixin") return null;
|
|
335
|
+
if (typeof decoded.platformUserId !== "string" || typeof decoded.chatId !== "string") return null;
|
|
336
|
+
return {
|
|
337
|
+
platform: "weixin",
|
|
338
|
+
platformUserId: decoded.platformUserId,
|
|
339
|
+
chatId: decoded.chatId
|
|
340
|
+
};
|
|
341
|
+
} catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// src/providers/claude-code/nativeSessions.ts
|
|
347
|
+
function resolveClaudeConfigDir(env = process.env) {
|
|
348
|
+
return join2(env.HOME || homedir2(), ".claude");
|
|
349
|
+
}
|
|
350
|
+
async function listRecoverableClaudeSessions(env = process.env) {
|
|
351
|
+
const projectsDir = join2(resolveClaudeConfigDir(env), "projects");
|
|
352
|
+
const historyIndex = await readClaudeHistoryIndex(env);
|
|
353
|
+
const candidates = [];
|
|
354
|
+
try {
|
|
355
|
+
const projectEntries = await readdir(projectsDir, { withFileTypes: true });
|
|
356
|
+
for (const entry of projectEntries) {
|
|
357
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
358
|
+
const projectDir = join2(projectsDir, String(entry.name));
|
|
359
|
+
const sessionEntries = await readdir(projectDir, { withFileTypes: true }).catch(() => []);
|
|
360
|
+
for (const sessionEntry of sessionEntries) {
|
|
361
|
+
if (!sessionEntry.isFile() || sessionEntry.isSymbolicLink()) continue;
|
|
362
|
+
const fileName = String(sessionEntry.name);
|
|
363
|
+
if (!fileName.endsWith(".jsonl")) continue;
|
|
364
|
+
const filePath = join2(projectDir, fileName);
|
|
365
|
+
const metadata = await stat(filePath).catch(() => null);
|
|
366
|
+
const parsedMeta = await readClaudeSessionMetadata(filePath).catch(() => null);
|
|
367
|
+
const historyMeta = historyIndex.get(basename(fileName, ".jsonl"));
|
|
368
|
+
const sessionId = basename(fileName, ".jsonl");
|
|
369
|
+
const bridgeTag = historyMeta?.bridgeTag ?? parseSessionBridgeName(parsedMeta?.sessionName);
|
|
370
|
+
const cwd = parsedMeta?.cwd ?? historyMeta?.project;
|
|
371
|
+
candidates.push({
|
|
372
|
+
id: sessionId,
|
|
373
|
+
providerId: "claude-code",
|
|
374
|
+
...cwd ? { cwd } : {},
|
|
375
|
+
title: parsedMeta?.aiTitle ?? parsedMeta?.lastPrompt ?? fileName,
|
|
376
|
+
...parsedMeta?.sessionName ? { resumeTitle: parsedMeta.sessionName } : historyMeta?.display ? { resumeTitle: historyMeta.display } : {},
|
|
377
|
+
...historyMeta?.timestamp ? { lastActivityAt: historyMeta.timestamp } : metadata ? { lastActivityAt: Math.trunc(metadata.mtimeMs) } : {},
|
|
378
|
+
...bridgeTag ? { bridgeBindingSource: "bridge_tag", bridgeTag } : {}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
} catch {
|
|
383
|
+
return [];
|
|
384
|
+
}
|
|
385
|
+
return candidates.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
386
|
+
}
|
|
387
|
+
async function findRecoverableClaudeSessionPath(sessionId, env = process.env) {
|
|
388
|
+
const trimmed = sessionId.trim();
|
|
389
|
+
if (!trimmed || trimmed.includes("/") || trimmed.includes("\\")) return null;
|
|
390
|
+
const projectsDir = join2(resolveClaudeConfigDir(env), "projects");
|
|
391
|
+
try {
|
|
392
|
+
const projectEntries = await readdir(projectsDir, { withFileTypes: true });
|
|
393
|
+
for (const entry of projectEntries) {
|
|
394
|
+
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
|
395
|
+
const sessionPath = join2(projectsDir, String(entry.name), `${trimmed}.jsonl`);
|
|
396
|
+
try {
|
|
397
|
+
const metadata = await stat(sessionPath);
|
|
398
|
+
if (metadata.isFile()) return sessionPath;
|
|
399
|
+
} catch {
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
} catch {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
async function ensureClaudeSessionBridgeMetadata(input) {
|
|
408
|
+
const env = input.env ?? process.env;
|
|
409
|
+
const sessionPath = await findRecoverableClaudeSessionPath(input.sessionId, env);
|
|
410
|
+
let changed = false;
|
|
411
|
+
let metadata = null;
|
|
412
|
+
if (sessionPath) {
|
|
413
|
+
changed = await normalizeClaudeSessionFileForResume(sessionPath);
|
|
414
|
+
metadata = await readClaudeSessionMetadata(sessionPath).catch(() => null);
|
|
415
|
+
if (metadata?.sessionName !== input.resumeTitle) {
|
|
416
|
+
const suffix = [
|
|
417
|
+
JSON.stringify({ type: "custom-title", customTitle: input.resumeTitle, sessionId: input.sessionId }),
|
|
418
|
+
JSON.stringify({ type: "agent-name", agentName: input.resumeTitle, sessionId: input.sessionId })
|
|
419
|
+
].join("\n");
|
|
420
|
+
const prefix = (await readFile2(sessionPath, "utf8")).endsWith("\n") ? "" : "\n";
|
|
421
|
+
await appendFile(sessionPath, `${prefix}${suffix}
|
|
422
|
+
`, "utf8");
|
|
423
|
+
changed = true;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
changed = await upsertClaudeHistoryDisplay({
|
|
427
|
+
sessionId: input.sessionId,
|
|
428
|
+
resumeTitle: input.resumeTitle,
|
|
429
|
+
project: input.cwd ?? metadata?.cwd,
|
|
430
|
+
env
|
|
431
|
+
}) || changed;
|
|
432
|
+
return changed;
|
|
433
|
+
}
|
|
434
|
+
async function hasClaudeSessionBridgeMetadata(input) {
|
|
435
|
+
const env = input.env ?? process.env;
|
|
436
|
+
const sessionPath = await findRecoverableClaudeSessionPath(input.sessionId, env);
|
|
437
|
+
if (!sessionPath) return false;
|
|
438
|
+
const metadata = await readClaudeSessionMetadata(sessionPath).catch(() => null);
|
|
439
|
+
return metadata?.sessionName === input.resumeTitle;
|
|
440
|
+
}
|
|
441
|
+
async function hasClaudeHistoryDisplay(input) {
|
|
442
|
+
const env = input.env ?? process.env;
|
|
443
|
+
const historyPath = join2(resolveClaudeConfigDir(env), "history.jsonl");
|
|
444
|
+
try {
|
|
445
|
+
const content = await readFile2(historyPath, "utf8");
|
|
446
|
+
for (const line of content.split(/\r?\n/)) {
|
|
447
|
+
if (!line.trim()) continue;
|
|
448
|
+
try {
|
|
449
|
+
const record = JSON.parse(line);
|
|
450
|
+
if (record.sessionId === input.sessionId) {
|
|
451
|
+
return typeof record.display === "string" && record.display.trim() === input.resumeTitle;
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
} catch {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
async function readClaudeSessionMetadata(filePath) {
|
|
462
|
+
const content = await readFile2(filePath, "utf8");
|
|
463
|
+
let aiTitle;
|
|
464
|
+
let lastPrompt;
|
|
465
|
+
let sessionName;
|
|
466
|
+
let cwd;
|
|
467
|
+
for (const line of content.split(/\r?\n/)) {
|
|
468
|
+
if (!line.trim()) continue;
|
|
469
|
+
try {
|
|
470
|
+
const record = JSON.parse(line);
|
|
471
|
+
if (typeof record.cwd === "string" && record.cwd.trim()) {
|
|
472
|
+
cwd = record.cwd.trim();
|
|
473
|
+
}
|
|
474
|
+
if (record.type === "ai-title" && typeof record.aiTitle === "string" && record.aiTitle.trim()) {
|
|
475
|
+
aiTitle = record.aiTitle.trim();
|
|
476
|
+
}
|
|
477
|
+
if (record.type === "last-prompt" && typeof record.lastPrompt === "string" && record.lastPrompt.trim()) {
|
|
478
|
+
lastPrompt = record.lastPrompt.trim();
|
|
479
|
+
}
|
|
480
|
+
if (record.type === "custom-title" && typeof record.customTitle === "string" && record.customTitle.trim()) {
|
|
481
|
+
sessionName = record.customTitle.trim();
|
|
482
|
+
}
|
|
483
|
+
if (record.type === "agent-name" && typeof record.agentName === "string" && record.agentName.trim()) {
|
|
484
|
+
sessionName = record.agentName.trim();
|
|
485
|
+
}
|
|
486
|
+
if (record.type === "user" && record.message && typeof record.message === "object") {
|
|
487
|
+
const message = record.message;
|
|
488
|
+
if (typeof message.sessionName === "string" && message.sessionName.trim()) {
|
|
489
|
+
sessionName = message.sessionName.trim();
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return { aiTitle, lastPrompt, sessionName, cwd };
|
|
496
|
+
}
|
|
497
|
+
async function readClaudeHistoryIndex(env) {
|
|
498
|
+
const historyPath = join2(resolveClaudeConfigDir(env), "history.jsonl");
|
|
499
|
+
const index = /* @__PURE__ */ new Map();
|
|
500
|
+
try {
|
|
501
|
+
const content = await readFile2(historyPath, "utf8");
|
|
502
|
+
for (const line of content.split(/\r?\n/)) {
|
|
503
|
+
if (!line.trim()) continue;
|
|
504
|
+
try {
|
|
505
|
+
const record = JSON.parse(line);
|
|
506
|
+
if (typeof record.sessionId !== "string" || !record.sessionId.trim()) continue;
|
|
507
|
+
const sessionId = record.sessionId.trim();
|
|
508
|
+
const previous = index.get(sessionId);
|
|
509
|
+
const timestamp = typeof record.timestamp === "number" ? record.timestamp : previous?.timestamp;
|
|
510
|
+
const project = typeof record.project === "string" && record.project.trim() ? record.project.trim() : previous?.project;
|
|
511
|
+
const display = typeof record.display === "string" ? record.display.trim() : "";
|
|
512
|
+
const bridgeTag = parseSessionBridgeName(display) ?? previous?.bridgeTag;
|
|
513
|
+
index.set(sessionId, { project, timestamp, display: display || previous?.display, bridgeTag });
|
|
514
|
+
} catch {
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
} catch {
|
|
518
|
+
return index;
|
|
519
|
+
}
|
|
520
|
+
return index;
|
|
521
|
+
}
|
|
522
|
+
async function upsertClaudeHistoryDisplay(input) {
|
|
523
|
+
const historyPath = join2(resolveClaudeConfigDir(input.env), "history.jsonl");
|
|
524
|
+
const lines = [];
|
|
525
|
+
let replaced = false;
|
|
526
|
+
let changed = false;
|
|
527
|
+
try {
|
|
528
|
+
const content = await readFile2(historyPath, "utf8");
|
|
529
|
+
for (const line of content.split(/\r?\n/)) {
|
|
530
|
+
if (!line.trim()) continue;
|
|
531
|
+
try {
|
|
532
|
+
const record = JSON.parse(line);
|
|
533
|
+
if (record.sessionId === input.sessionId) {
|
|
534
|
+
replaced = true;
|
|
535
|
+
if (record.display !== input.resumeTitle) changed = true;
|
|
536
|
+
if (typeof record.project !== "string" && input.project) changed = true;
|
|
537
|
+
lines.push(JSON.stringify({
|
|
538
|
+
...record,
|
|
539
|
+
display: input.resumeTitle,
|
|
540
|
+
...typeof record.project !== "string" && input.project ? { project: input.project } : {}
|
|
541
|
+
}));
|
|
542
|
+
} else {
|
|
543
|
+
lines.push(line);
|
|
544
|
+
}
|
|
545
|
+
} catch {
|
|
546
|
+
lines.push(line);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} catch {
|
|
550
|
+
}
|
|
551
|
+
if (!replaced) {
|
|
552
|
+
changed = true;
|
|
553
|
+
lines.push(JSON.stringify({
|
|
554
|
+
display: input.resumeTitle,
|
|
555
|
+
pastedContents: {},
|
|
556
|
+
timestamp: Date.now(),
|
|
557
|
+
sessionId: input.sessionId,
|
|
558
|
+
...input.project ? { project: input.project } : {}
|
|
559
|
+
}));
|
|
560
|
+
}
|
|
561
|
+
if (!changed) return false;
|
|
562
|
+
await mkdir2(dirname3(historyPath), { recursive: true });
|
|
563
|
+
await writeFile2(historyPath, `${lines.join("\n")}
|
|
564
|
+
`, "utf8");
|
|
565
|
+
return true;
|
|
566
|
+
}
|
|
567
|
+
async function normalizeClaudeSessionFileForResume(sessionPath) {
|
|
568
|
+
const content = await readFile2(sessionPath, "utf8");
|
|
569
|
+
const nextLines = [];
|
|
570
|
+
let changed = false;
|
|
571
|
+
let hasPermissionMode = false;
|
|
572
|
+
let sawSdkCliEntrypoint = false;
|
|
573
|
+
for (const line of content.split(/\r?\n/)) {
|
|
574
|
+
if (!line.trim()) continue;
|
|
575
|
+
try {
|
|
576
|
+
const record = JSON.parse(line);
|
|
577
|
+
if (record.type === "permission-mode") hasPermissionMode = true;
|
|
578
|
+
if (record.entrypoint === "sdk-cli") {
|
|
579
|
+
sawSdkCliEntrypoint = true;
|
|
580
|
+
nextLines.push(JSON.stringify({
|
|
581
|
+
...record,
|
|
582
|
+
entrypoint: "cli"
|
|
583
|
+
}));
|
|
584
|
+
changed = true;
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
nextLines.push(line);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
nextLines.push(line);
|
|
592
|
+
}
|
|
593
|
+
if (sawSdkCliEntrypoint && !hasPermissionMode) {
|
|
594
|
+
const sessionId = basename(sessionPath, ".jsonl");
|
|
595
|
+
nextLines.unshift(JSON.stringify({
|
|
596
|
+
type: "permission-mode",
|
|
597
|
+
permissionMode: "bypassPermissions",
|
|
598
|
+
sessionId
|
|
599
|
+
}));
|
|
600
|
+
changed = true;
|
|
601
|
+
}
|
|
602
|
+
if (!changed) return false;
|
|
603
|
+
await writeFile2(sessionPath, `${nextLines.join("\n")}
|
|
604
|
+
`, "utf8");
|
|
605
|
+
return true;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// src/providers/codex/nativeSessions.ts
|
|
609
|
+
import Database from "better-sqlite3";
|
|
610
|
+
import { readdir as readdir2, readFile as readFile3, stat as stat2 } from "node:fs/promises";
|
|
611
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
612
|
+
import { homedir as homedir3 } from "node:os";
|
|
613
|
+
import { basename as basename2, join as join3 } from "node:path";
|
|
614
|
+
function resolveCodexSessionsRoot(env = process.env) {
|
|
615
|
+
return join3(env.CODEX_HOME || join3(env.HOME || homedir3(), ".codex"), "sessions");
|
|
616
|
+
}
|
|
617
|
+
function isMatchingCodexRolloutFile(name) {
|
|
618
|
+
return name.startsWith("rollout-") && name.endsWith(".jsonl");
|
|
619
|
+
}
|
|
620
|
+
function extractCodexSessionId(name) {
|
|
621
|
+
if (!isMatchingCodexRolloutFile(name)) return null;
|
|
622
|
+
const withoutExt = basename2(name, ".jsonl");
|
|
623
|
+
const match = withoutExt.match(/^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-(.+)$/);
|
|
624
|
+
if (!match?.[1]) return null;
|
|
625
|
+
return match[1];
|
|
626
|
+
}
|
|
627
|
+
async function listRecoverableCodexSessions(env = process.env) {
|
|
628
|
+
const root = resolveCodexSessionsRoot(env);
|
|
629
|
+
const index = await readCodexSessionIndex(env);
|
|
630
|
+
const threadMeta = readCodexThreadMetadata(env);
|
|
631
|
+
const candidates = [];
|
|
632
|
+
async function walk(currentDir, depth) {
|
|
633
|
+
if (depth > 8) return;
|
|
634
|
+
const entries = await readdir2(currentDir, { withFileTypes: true }).catch(() => []);
|
|
635
|
+
for (const entry of entries) {
|
|
636
|
+
if (entry.isSymbolicLink()) continue;
|
|
637
|
+
const name = String(entry.name);
|
|
638
|
+
const path = join3(currentDir, name);
|
|
639
|
+
if (entry.isDirectory()) {
|
|
640
|
+
await walk(path, depth + 1);
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
if (!entry.isFile()) continue;
|
|
644
|
+
const sessionId = extractCodexSessionId(name);
|
|
645
|
+
if (!sessionId) continue;
|
|
646
|
+
const indexed = index.get(sessionId);
|
|
647
|
+
const threaded = threadMeta.get(sessionId);
|
|
648
|
+
const sessionMeta = await readCodexSessionMeta(path);
|
|
649
|
+
const resolvedTitle = indexed?.threadName ?? threaded?.title ?? name;
|
|
650
|
+
const resolvedLastActivityAt = indexed?.updatedAtMs ?? threaded?.updatedAtMs ?? sessionMeta.lastActivityAt;
|
|
651
|
+
candidates.push({
|
|
652
|
+
id: sessionId,
|
|
653
|
+
providerId: "codex",
|
|
654
|
+
...sessionMeta.cwd ? { cwd: sessionMeta.cwd } : {},
|
|
655
|
+
...resolvedLastActivityAt ? { lastActivityAt: resolvedLastActivityAt } : {},
|
|
656
|
+
title: resolvedTitle,
|
|
657
|
+
...indexed?.threadName ?? threaded?.title ? { resumeTitle: indexed?.threadName ?? threaded?.title } : {}
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
await walk(root, 0);
|
|
662
|
+
return candidates.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
663
|
+
}
|
|
664
|
+
async function findRecoverableCodexSessionPath(sessionId, env = process.env) {
|
|
665
|
+
const root = resolveCodexSessionsRoot(env);
|
|
666
|
+
const trimmed = sessionId.trim();
|
|
667
|
+
if (!trimmed || trimmed.includes("/") || trimmed.includes("\\")) return null;
|
|
668
|
+
async function walk(currentDir, depth) {
|
|
669
|
+
if (depth > 8) return null;
|
|
670
|
+
const entries = await readdir2(currentDir, { withFileTypes: true }).catch(() => []);
|
|
671
|
+
for (const entry of entries) {
|
|
672
|
+
if (entry.isSymbolicLink()) continue;
|
|
673
|
+
const name = String(entry.name);
|
|
674
|
+
const path = join3(currentDir, name);
|
|
675
|
+
if (entry.isDirectory()) {
|
|
676
|
+
const found = await walk(path, depth + 1);
|
|
677
|
+
if (found) return found;
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
if (!entry.isFile()) continue;
|
|
681
|
+
if (extractCodexSessionId(name) === trimmed) return path;
|
|
682
|
+
}
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
return await walk(root, 0);
|
|
686
|
+
}
|
|
687
|
+
async function readCodexSessionIndex(env) {
|
|
688
|
+
const indexPath = join3(env.CODEX_HOME || join3(env.HOME || homedir3(), ".codex"), "session_index.jsonl");
|
|
689
|
+
const index = /* @__PURE__ */ new Map();
|
|
690
|
+
try {
|
|
691
|
+
const content = await readFile3(indexPath, "utf8");
|
|
692
|
+
for (const line of content.split(/\r?\n/)) {
|
|
693
|
+
if (!line.trim()) continue;
|
|
694
|
+
try {
|
|
695
|
+
const record = JSON.parse(line);
|
|
696
|
+
if (typeof record.id !== "string" || !record.id.trim()) continue;
|
|
697
|
+
const updatedAtMs = typeof record.updated_at === "string" ? Date.parse(record.updated_at) : void 0;
|
|
698
|
+
index.set(record.id.trim(), {
|
|
699
|
+
threadName: typeof record.thread_name === "string" && record.thread_name.trim() ? record.thread_name.trim() : void 0,
|
|
700
|
+
updatedAtMs: Number.isFinite(updatedAtMs) ? Math.trunc(updatedAtMs) : void 0
|
|
701
|
+
});
|
|
702
|
+
} catch {
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
} catch {
|
|
706
|
+
return index;
|
|
707
|
+
}
|
|
708
|
+
return index;
|
|
709
|
+
}
|
|
710
|
+
function readCodexThreadMetadata(env) {
|
|
711
|
+
const dbPath = join3(env.CODEX_HOME || join3(env.HOME || homedir3(), ".codex"), "state_5.sqlite");
|
|
712
|
+
const metadata = /* @__PURE__ */ new Map();
|
|
713
|
+
if (!existsSync3(dbPath)) return metadata;
|
|
714
|
+
let db = null;
|
|
715
|
+
try {
|
|
716
|
+
db = new Database(dbPath, { readonly: true });
|
|
717
|
+
const columns = db.prepare("PRAGMA table_info(threads)").all();
|
|
718
|
+
if (!columns.some((column) => column.name === "id")) return metadata;
|
|
719
|
+
const rows = db.prepare("SELECT id, title, updated_at_ms FROM threads").all();
|
|
720
|
+
for (const row of rows) {
|
|
721
|
+
if (typeof row.id !== "string" || !row.id.trim()) continue;
|
|
722
|
+
metadata.set(row.id.trim(), {
|
|
723
|
+
title: typeof row.title === "string" && row.title.trim() ? row.title.trim() : void 0,
|
|
724
|
+
updatedAtMs: typeof row.updated_at_ms === "number" ? Math.trunc(row.updated_at_ms) : typeof row.updated_at_ms === "bigint" ? Number(row.updated_at_ms) : void 0
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
} catch {
|
|
728
|
+
return metadata;
|
|
729
|
+
} finally {
|
|
730
|
+
db?.close();
|
|
731
|
+
}
|
|
732
|
+
return metadata;
|
|
733
|
+
}
|
|
734
|
+
async function readCodexSessionMeta(filePath) {
|
|
735
|
+
try {
|
|
736
|
+
const content = await readFile3(filePath, "utf8");
|
|
737
|
+
let cwd;
|
|
738
|
+
for (const line of content.split(/\r?\n/)) {
|
|
739
|
+
if (!line.trim()) continue;
|
|
740
|
+
try {
|
|
741
|
+
const record = JSON.parse(line);
|
|
742
|
+
if (typeof record.cwd === "string" && record.cwd.trim()) {
|
|
743
|
+
cwd = record.cwd.trim();
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
if (record.payload && typeof record.payload === "object") {
|
|
747
|
+
const payload = record.payload;
|
|
748
|
+
if (typeof payload.cwd === "string" && payload.cwd.trim()) {
|
|
749
|
+
cwd = payload.cwd.trim();
|
|
750
|
+
break;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
} catch {
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
const metadata = await stat2(filePath).catch(() => null);
|
|
757
|
+
return {
|
|
758
|
+
cwd,
|
|
759
|
+
lastActivityAt: metadata ? Math.trunc(metadata.mtimeMs) : void 0
|
|
760
|
+
};
|
|
761
|
+
} catch {
|
|
762
|
+
return {};
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// src/session/providerAutoAttach.ts
|
|
767
|
+
import { nanoid } from "nanoid";
|
|
768
|
+
|
|
769
|
+
// src/providers/codex/sessionIndex.ts
|
|
770
|
+
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
771
|
+
import { homedir as homedir4 } from "node:os";
|
|
772
|
+
import { dirname as dirname4, join as join4 } from "node:path";
|
|
773
|
+
function resolveCodexIndexPath(env = process.env) {
|
|
774
|
+
return join4(env.CODEX_HOME || join4(env.HOME || homedir4(), ".codex"), "session_index.jsonl");
|
|
775
|
+
}
|
|
776
|
+
async function upsertCodexSessionIndexEntry(input, env = process.env) {
|
|
777
|
+
const indexPath = resolveCodexIndexPath(env);
|
|
778
|
+
const lines = [];
|
|
779
|
+
let replaced = false;
|
|
780
|
+
try {
|
|
781
|
+
const content = await readFile4(indexPath, "utf8");
|
|
782
|
+
for (const line of content.split(/\r?\n/)) {
|
|
783
|
+
if (!line.trim()) continue;
|
|
784
|
+
try {
|
|
785
|
+
const record = JSON.parse(line);
|
|
786
|
+
if (record.id === input.sessionId) {
|
|
787
|
+
lines.push(JSON.stringify({
|
|
788
|
+
...record,
|
|
789
|
+
id: input.sessionId,
|
|
790
|
+
thread_name: input.threadName,
|
|
791
|
+
updated_at: input.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
792
|
+
}));
|
|
793
|
+
replaced = true;
|
|
794
|
+
} else {
|
|
795
|
+
lines.push(line);
|
|
796
|
+
}
|
|
797
|
+
} catch {
|
|
798
|
+
lines.push(line);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
} catch {
|
|
802
|
+
}
|
|
803
|
+
if (!replaced) {
|
|
804
|
+
lines.push(JSON.stringify({
|
|
805
|
+
id: input.sessionId,
|
|
806
|
+
thread_name: input.threadName,
|
|
807
|
+
updated_at: input.updatedAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
808
|
+
}));
|
|
809
|
+
}
|
|
810
|
+
await mkdir3(dirname4(indexPath), { recursive: true });
|
|
811
|
+
await writeFile3(indexPath, `${lines.join("\n")}
|
|
812
|
+
`, "utf8");
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// src/providers/codex/nativeThreads.ts
|
|
816
|
+
import Database2 from "better-sqlite3";
|
|
817
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
818
|
+
import { readFile as readFile5, writeFile as writeFile4 } from "node:fs/promises";
|
|
819
|
+
import { homedir as homedir5 } from "node:os";
|
|
820
|
+
import { join as join5 } from "node:path";
|
|
821
|
+
function resolveCodexStateDbPath(env = process.env) {
|
|
822
|
+
return join5(env.CODEX_HOME || join5(env.HOME || homedir5(), ".codex"), "state_5.sqlite");
|
|
823
|
+
}
|
|
824
|
+
async function syncCodexThreadForResume(input) {
|
|
825
|
+
const env = input.env ?? process.env;
|
|
826
|
+
const dbPath = resolveCodexStateDbPath(env);
|
|
827
|
+
if (!existsSync4(dbPath)) return false;
|
|
828
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
829
|
+
const db = new Database2(dbPath);
|
|
830
|
+
try {
|
|
831
|
+
const columns = db.prepare("PRAGMA table_info(threads)").all();
|
|
832
|
+
if (!columns.some((column) => column.name === "id")) return false;
|
|
833
|
+
const updatedAtMs = Date.now();
|
|
834
|
+
const updatedAt = Math.trunc(updatedAtMs / 1e3);
|
|
835
|
+
const result = db.prepare(`
|
|
836
|
+
UPDATE threads
|
|
837
|
+
SET
|
|
838
|
+
source = 'cli',
|
|
839
|
+
cwd = @cwd,
|
|
840
|
+
title = @resumeTitle,
|
|
841
|
+
updated_at = @updatedAt,
|
|
842
|
+
updated_at_ms = @updatedAtMs
|
|
843
|
+
WHERE id = @sessionId
|
|
844
|
+
`).run({
|
|
845
|
+
sessionId: input.sessionId,
|
|
846
|
+
cwd: input.cwd,
|
|
847
|
+
resumeTitle: input.resumeTitle,
|
|
848
|
+
updatedAt,
|
|
849
|
+
updatedAtMs
|
|
850
|
+
});
|
|
851
|
+
if (result.changes > 0) {
|
|
852
|
+
const row = db.prepare("SELECT rollout_path FROM threads WHERE id = ?").get(input.sessionId);
|
|
853
|
+
if (typeof row?.rollout_path === "string" && row.rollout_path) {
|
|
854
|
+
await syncCodexRolloutSessionMeta(row.rollout_path);
|
|
855
|
+
}
|
|
856
|
+
return true;
|
|
857
|
+
}
|
|
858
|
+
} finally {
|
|
859
|
+
db.close();
|
|
860
|
+
}
|
|
861
|
+
await delay(100);
|
|
862
|
+
}
|
|
863
|
+
return false;
|
|
864
|
+
}
|
|
865
|
+
async function syncCodexRolloutSessionMeta(rolloutPath) {
|
|
866
|
+
if (!existsSync4(rolloutPath)) return;
|
|
867
|
+
const content = await readFile5(rolloutPath, "utf8");
|
|
868
|
+
const lines = content.split(/\r?\n/);
|
|
869
|
+
if (!lines[0]?.trim()) return;
|
|
870
|
+
try {
|
|
871
|
+
const first = JSON.parse(lines[0]);
|
|
872
|
+
if (first.type !== "session_meta" || !first.payload || typeof first.payload !== "object") return;
|
|
873
|
+
const payload = first.payload;
|
|
874
|
+
payload.source = "cli";
|
|
875
|
+
payload.originator = "codex-tui";
|
|
876
|
+
first.payload = payload;
|
|
877
|
+
lines[0] = JSON.stringify(first);
|
|
878
|
+
await writeFile4(rolloutPath, lines.join("\n"), "utf8");
|
|
879
|
+
} catch {
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
async function delay(ms) {
|
|
883
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// src/session/providerAutoAttach.ts
|
|
887
|
+
async function listUnattachedRecoverableSessions(input) {
|
|
888
|
+
if (!input.provider.listRecoverableSessions) return [];
|
|
889
|
+
const attachedIds = new Set(
|
|
890
|
+
[input.currentSession].filter((session) => Boolean(session && session.providerId === input.providerId)).map((session) => session.providerSessionId).filter((value) => typeof value === "string" && value.length > 0)
|
|
891
|
+
);
|
|
892
|
+
return (await input.provider.listRecoverableSessions()).filter((candidate) => !attachedIds.has(candidate.id));
|
|
893
|
+
}
|
|
894
|
+
async function selectBestRecoverableSession(input) {
|
|
895
|
+
const persistedBinding = input.lastProviderSessions?.get(input.providerId) ?? null;
|
|
896
|
+
if (persistedBinding) {
|
|
897
|
+
const candidates2 = await listUnattachedRecoverableSessions({
|
|
898
|
+
provider: input.provider,
|
|
899
|
+
providerId: input.providerId,
|
|
900
|
+
currentSession: input.currentSession
|
|
901
|
+
});
|
|
902
|
+
const exact = candidates2.find((candidate2) => candidate2.id === persistedBinding.providerSessionId);
|
|
903
|
+
if (exact) return { candidate: exact, matchedBinding: true, bindingSource: "binding_table" };
|
|
904
|
+
}
|
|
905
|
+
if (input.allowHeuristicMatch === false) return null;
|
|
906
|
+
const candidates = await listUnattachedRecoverableSessions({
|
|
907
|
+
provider: input.provider,
|
|
908
|
+
providerId: input.providerId,
|
|
909
|
+
currentSession: input.currentSession
|
|
910
|
+
});
|
|
911
|
+
candidates.sort((a, b) => {
|
|
912
|
+
const aMatch = a.cwd === input.targetCwd ? 1 : 0;
|
|
913
|
+
const bMatch = b.cwd === input.targetCwd ? 1 : 0;
|
|
914
|
+
if (aMatch !== bMatch) return bMatch - aMatch;
|
|
915
|
+
return (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0);
|
|
916
|
+
});
|
|
917
|
+
const candidate = candidates[0];
|
|
918
|
+
if (!candidate) return null;
|
|
919
|
+
if (candidate.cwd && candidate.cwd !== input.targetCwd) return null;
|
|
920
|
+
return {
|
|
921
|
+
candidate,
|
|
922
|
+
matchedBinding: false,
|
|
923
|
+
bindingSource: candidate.bridgeBindingSource === "sidecar" ? "sidecar" : "heuristic"
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
async function attachProviderSessionToBridge(input) {
|
|
927
|
+
const bridgeSessionId = `bs_${nanoid(10)}`;
|
|
928
|
+
const resumeTitle = input.resumeTitle ?? buildSessionBridgeName({
|
|
929
|
+
platform: "weixin",
|
|
930
|
+
platformUserId: input.user.platformUserId,
|
|
931
|
+
chatId: input.chatId
|
|
932
|
+
});
|
|
933
|
+
const attached = await input.provider.attachSession?.({
|
|
934
|
+
candidateId: input.providerSessionId,
|
|
935
|
+
bridgeSessionId,
|
|
936
|
+
cwd: input.cwd
|
|
937
|
+
});
|
|
938
|
+
const now = Date.now();
|
|
939
|
+
const updated = {
|
|
940
|
+
id: bridgeSessionId,
|
|
941
|
+
chatId: input.chatId,
|
|
942
|
+
ownerUserId: input.user.id,
|
|
943
|
+
providerId: input.providerId,
|
|
944
|
+
cwd: input.cwd,
|
|
945
|
+
recoverySource: input.recoverySource,
|
|
946
|
+
resumeTitle,
|
|
947
|
+
providerSessionId: attached?.providerSessionId,
|
|
948
|
+
status: attached?.status ?? "starting",
|
|
949
|
+
createdAt: now,
|
|
950
|
+
lastActivityAt: now
|
|
951
|
+
};
|
|
952
|
+
if (input.shouldCommit && !input.shouldCommit()) return updated;
|
|
953
|
+
input.conversationStore.setCurrent(updated);
|
|
954
|
+
input.lastProviderSessions?.set(input.providerId, {
|
|
955
|
+
providerSessionId: updated.providerSessionId ?? input.providerSessionId,
|
|
956
|
+
cwd: updated.cwd
|
|
957
|
+
});
|
|
958
|
+
if (updated.providerSessionId) {
|
|
959
|
+
if (updated.providerId === "codex" && updated.resumeTitle) {
|
|
960
|
+
await upsertCodexSessionIndexEntry({
|
|
961
|
+
sessionId: updated.providerSessionId,
|
|
962
|
+
threadName: updated.resumeTitle
|
|
963
|
+
});
|
|
964
|
+
await syncCodexThreadForResume({
|
|
965
|
+
sessionId: updated.providerSessionId,
|
|
966
|
+
resumeTitle: updated.resumeTitle,
|
|
967
|
+
cwd: updated.cwd
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
return updated;
|
|
972
|
+
}
|
|
973
|
+
async function autoAttachProviderSessionForMessage(input) {
|
|
974
|
+
const selection = await selectBestRecoverableSession({
|
|
975
|
+
provider: input.provider,
|
|
976
|
+
providerId: input.defaultProviderId,
|
|
977
|
+
targetCwd: input.user.currentConversation?.cwd ?? input.defaultCwd,
|
|
978
|
+
lastProviderSessions: input.lastProviderSessions,
|
|
979
|
+
currentSession: input.conversationStore.getCurrent(),
|
|
980
|
+
allowHeuristicMatch: false
|
|
981
|
+
});
|
|
982
|
+
if (!selection) return null;
|
|
983
|
+
const session = await attachProviderSessionToBridge({
|
|
984
|
+
conversationStore: input.conversationStore,
|
|
985
|
+
lastProviderSessions: input.lastProviderSessions,
|
|
986
|
+
provider: input.provider,
|
|
987
|
+
user: input.user,
|
|
988
|
+
providerId: input.defaultProviderId,
|
|
989
|
+
providerSessionId: selection.candidate.id,
|
|
990
|
+
chatId: input.message.chatId,
|
|
991
|
+
cwd: selection.candidate.cwd ?? input.user.currentConversation?.cwd ?? input.defaultCwd,
|
|
992
|
+
recoverySource: selection.bindingSource,
|
|
993
|
+
resumeTitle: selection.candidate.resumeTitle,
|
|
994
|
+
shouldCommit: input.shouldCommit
|
|
995
|
+
});
|
|
996
|
+
return { session, matchedBinding: selection.matchedBinding, bindingSource: selection.bindingSource };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// src/admin/channelAdminRoutes.ts
|
|
1000
|
+
function registerChannelAdminRoutes(input) {
|
|
1001
|
+
let wechat = input.wechat;
|
|
1002
|
+
const configPath = input.configPath ?? process.env.BRIDGE_CONFIG ?? defaultConfigPath();
|
|
1003
|
+
input.channel?.onHealthChange?.(() => {
|
|
1004
|
+
input.events?.emit({
|
|
1005
|
+
type: "channel.plugin-status-changed",
|
|
1006
|
+
plugin_id: "weixin",
|
|
1007
|
+
status: toWechatPluginStatus(wechat, input.users, input.channel)
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
1010
|
+
input.app.get("/api/channel/plugins", async () => [toWechatPluginStatus(wechat, input.users, input.channel)]);
|
|
1011
|
+
input.app.get("/api/channel/wechat/runtime-config", async () => wechat ?? { enabled: false });
|
|
1012
|
+
input.app.get("/api/channel/state", async () => ({
|
|
1013
|
+
activeUser: input.users.getActiveUser(),
|
|
1014
|
+
plugin: toWechatPluginStatus(wechat, input.users, input.channel),
|
|
1015
|
+
settings: input.defaults ?? { defaultProvider: "claude-code", defaultWorkspace: process.cwd() },
|
|
1016
|
+
runtimeConfig: wechat ?? null,
|
|
1017
|
+
lastProviderSessions: input.lastProviderSessions?.list() ?? {},
|
|
1018
|
+
quota: toQuotaView(input.users, input.weixinStateStore)
|
|
1019
|
+
}));
|
|
1020
|
+
input.app.post("/api/channel/plugins/enable", async (request, reply) => {
|
|
1021
|
+
if (request.body.plugin_id !== "weixin") {
|
|
1022
|
+
return reply.code(400).send({ ok: false, error: "unknown_channel_plugin" });
|
|
1023
|
+
}
|
|
1024
|
+
const config = request.body.config ?? {};
|
|
1025
|
+
const credentials = typeof config.credentials === "object" && config.credentials ? config.credentials : {};
|
|
1026
|
+
const previousWechat = wechat;
|
|
1027
|
+
const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : typeof credentials.baseUrl === "string" ? credentials.baseUrl : previousWechat?.baseUrl;
|
|
1028
|
+
const token = typeof config.token === "string" ? config.token : typeof credentials.bot_token === "string" ? credentials.bot_token : void 0;
|
|
1029
|
+
const accountId = typeof credentials.account_id === "string" ? credentials.account_id : previousWechat?.accountId;
|
|
1030
|
+
if (!baseUrl) return reply.code(400).send({ ok: false, error: "wechat_base_url_required" });
|
|
1031
|
+
const nextWechat = { enabled: true, baseUrl, token, accountId };
|
|
1032
|
+
await input.onWechatConfigChanged?.(nextWechat);
|
|
1033
|
+
if (token && accountId) {
|
|
1034
|
+
await persistWechatCredentialsToConfigFile({
|
|
1035
|
+
configPath,
|
|
1036
|
+
accountId,
|
|
1037
|
+
token,
|
|
1038
|
+
baseUrl
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
wechat = nextWechat;
|
|
1042
|
+
input.events?.emit({
|
|
1043
|
+
type: "channel.plugin-status-changed",
|
|
1044
|
+
plugin_id: "weixin",
|
|
1045
|
+
status: toWechatPluginStatus(wechat, input.users, input.channel)
|
|
1046
|
+
});
|
|
1047
|
+
return { ok: true };
|
|
1048
|
+
});
|
|
1049
|
+
input.app.get("/api/channel/weixin/login", async (_request, reply) => {
|
|
1050
|
+
const client = new WeixinDirectLoginClient();
|
|
1051
|
+
reply.raw.writeHead(200, {
|
|
1052
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
1053
|
+
"cache-control": "no-cache",
|
|
1054
|
+
connection: "keep-alive"
|
|
1055
|
+
});
|
|
1056
|
+
try {
|
|
1057
|
+
const qr = await client.fetchQrCode();
|
|
1058
|
+
reply.raw.write(`event: qr
|
|
1059
|
+
`);
|
|
1060
|
+
reply.raw.write(`data: ${JSON.stringify({ qrcodeData: qr.qrcodeData })}
|
|
1061
|
+
|
|
1062
|
+
`);
|
|
1063
|
+
while (true) {
|
|
1064
|
+
await new Promise((resolve) => setTimeout(resolve, client.pollIntervalMs));
|
|
1065
|
+
const status = await client.pollQrCodeStatus(qr.ticket);
|
|
1066
|
+
if (status.status === "waiting") continue;
|
|
1067
|
+
if (status.status === "scanned") {
|
|
1068
|
+
reply.raw.write(`event: scanned
|
|
1069
|
+
`);
|
|
1070
|
+
reply.raw.write("data: {}\n\n");
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (status.status === "confirmed") {
|
|
1074
|
+
reply.raw.write(`event: done
|
|
1075
|
+
`);
|
|
1076
|
+
reply.raw.write(`data: ${JSON.stringify({
|
|
1077
|
+
accountId: status.accountId,
|
|
1078
|
+
botToken: status.botToken,
|
|
1079
|
+
baseUrl: status.baseUrl
|
|
1080
|
+
})}
|
|
1081
|
+
|
|
1082
|
+
`);
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
reply.raw.write(`event: error
|
|
1086
|
+
`);
|
|
1087
|
+
reply.raw.write(`data: ${JSON.stringify({ message: "QR code expired" })}
|
|
1088
|
+
|
|
1089
|
+
`);
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1094
|
+
reply.raw.write(`event: error
|
|
1095
|
+
`);
|
|
1096
|
+
reply.raw.write(`data: ${JSON.stringify({ message })}
|
|
1097
|
+
|
|
1098
|
+
`);
|
|
1099
|
+
}
|
|
1100
|
+
reply.raw.end();
|
|
1101
|
+
return reply.hijack();
|
|
1102
|
+
});
|
|
1103
|
+
input.app.post("/api/channel/plugins/disable", async (request, reply) => {
|
|
1104
|
+
if (request.body.plugin_id !== "weixin") {
|
|
1105
|
+
return reply.code(400).send({ ok: false, error: "unknown_channel_plugin" });
|
|
1106
|
+
}
|
|
1107
|
+
try {
|
|
1108
|
+
const runtimeSession = input.conversation?.getCurrent();
|
|
1109
|
+
if (runtimeSession) {
|
|
1110
|
+
const provider = input.providers?.find((candidate) => candidate.id === runtimeSession.providerId);
|
|
1111
|
+
await provider?.stopSession(runtimeSession.id);
|
|
1112
|
+
}
|
|
1113
|
+
input.conversation?.clear();
|
|
1114
|
+
input.events?.emit({ type: "channel.current-session-changed" });
|
|
1115
|
+
const activeUser = input.users.getActiveUser();
|
|
1116
|
+
if (activeUser) {
|
|
1117
|
+
input.users.clearActiveUser(activeUser.id);
|
|
1118
|
+
}
|
|
1119
|
+
const nextWechat = { enabled: false };
|
|
1120
|
+
await input.onWechatConfigChanged?.(nextWechat);
|
|
1121
|
+
wechat = nextWechat;
|
|
1122
|
+
input.events?.emit({
|
|
1123
|
+
type: "channel.plugin-status-changed",
|
|
1124
|
+
plugin_id: "weixin",
|
|
1125
|
+
status: toWechatPluginStatus(wechat, input.users, input.channel)
|
|
1126
|
+
});
|
|
1127
|
+
await deleteConfigFile(configPath);
|
|
1128
|
+
return { ok: true };
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
input.app.log.error({ err }, "[disable] failed");
|
|
1131
|
+
return reply.code(500).send({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
1132
|
+
}
|
|
1133
|
+
});
|
|
1134
|
+
input.app.get("/api/channel/active-user", async () => input.users.getActiveUser());
|
|
1135
|
+
input.app.get("/api/channel/providers/:providerId/recoverable-sessions", async (request, reply) => {
|
|
1136
|
+
const provider = input.providers?.find((candidate) => candidate.id === request.params.providerId);
|
|
1137
|
+
if (!provider?.listRecoverableSessions) {
|
|
1138
|
+
return reply.code(404).send({ ok: false, error: "provider_session_listing_not_supported" });
|
|
1139
|
+
}
|
|
1140
|
+
const sessions = await Promise.all((await listUnattachedRecoverableSessions({
|
|
1141
|
+
provider,
|
|
1142
|
+
providerId: request.params.providerId === "codex" ? "codex" : "claude-code",
|
|
1143
|
+
currentSession: input.conversation?.getCurrent()
|
|
1144
|
+
})).map(async (session) => ({
|
|
1145
|
+
...session,
|
|
1146
|
+
preferredResumeMode: buildPreferredResumeMode(session.providerId, session.resumeTitle),
|
|
1147
|
+
preferredResumeCommand: buildPreferredResumeCommand(session.providerId, session.id, session.resumeTitle),
|
|
1148
|
+
providerResumeCommand: buildProviderResumeCommand(session.providerId, session.id),
|
|
1149
|
+
providerResumeByTitleCommand: buildProviderResumeByTitleCommand(session.providerId, session.resumeTitle),
|
|
1150
|
+
providerResumeTitleSynced: await resolveRecoverableProviderResumeTitleSynced(session),
|
|
1151
|
+
providerResumeHistorySynced: await resolveRecoverableProviderResumeHistorySynced(session),
|
|
1152
|
+
providerResumeRepairable: session.providerId === "claude-code" && Boolean(session.id && session.resumeTitle && await findRecoverableClaudeSessionPath(session.id))
|
|
1153
|
+
})));
|
|
1154
|
+
const limit = Number.parseInt(request.query.limit ?? "", 10);
|
|
1155
|
+
if (!Number.isFinite(limit) || limit <= 0) return sessions;
|
|
1156
|
+
const cursor = typeof request.query.cursor === "string" && request.query.cursor.trim() ? request.query.cursor.trim() : null;
|
|
1157
|
+
const startIndex = cursor ? sessions.findIndex((session) => session.id === cursor) + 1 : 0;
|
|
1158
|
+
const safeStart = startIndex > 0 ? startIndex : 0;
|
|
1159
|
+
const items = sessions.slice(safeStart, safeStart + limit);
|
|
1160
|
+
const nextCursor = safeStart + items.length < sessions.length ? items.at(-1)?.id ?? null : null;
|
|
1161
|
+
return { items, nextCursor };
|
|
1162
|
+
});
|
|
1163
|
+
input.app.post("/api/channel/sessions/attach", async (request, reply) => {
|
|
1164
|
+
const provider = input.providers?.find((candidate) => candidate.id === request.body.providerId);
|
|
1165
|
+
if (!provider?.attachSession) {
|
|
1166
|
+
return reply.code(404).send({ ok: false, error: "provider_session_attach_not_supported" });
|
|
1167
|
+
}
|
|
1168
|
+
const user = input.users.isActiveUser(PRIMARY_WEIXIN_PLATFORM, request.body.platformUserId);
|
|
1169
|
+
if (!user) {
|
|
1170
|
+
return reply.code(404).send({ ok: false, error: "active_wechat_user_not_found" });
|
|
1171
|
+
}
|
|
1172
|
+
if (!input.conversation) {
|
|
1173
|
+
return reply.code(500).send({ ok: false, error: "current_conversation_store_unavailable" });
|
|
1174
|
+
}
|
|
1175
|
+
const recoverableCandidate = provider.listRecoverableSessions ? (await provider.listRecoverableSessions()).find((candidate) => candidate.id === request.body.providerSessionId) : void 0;
|
|
1176
|
+
const attached = await attachProviderSessionToBridge({
|
|
1177
|
+
conversationStore: input.conversation,
|
|
1178
|
+
lastProviderSessions: input.lastProviderSessions,
|
|
1179
|
+
provider,
|
|
1180
|
+
user,
|
|
1181
|
+
providerId: request.body.providerId === "codex" ? "codex" : "claude-code",
|
|
1182
|
+
providerSessionId: request.body.providerSessionId,
|
|
1183
|
+
chatId: request.body.chatId ?? user.platformUserId,
|
|
1184
|
+
cwd: request.body.cwd ?? recoverableCandidate?.cwd ?? input.defaults?.defaultWorkspace ?? process.cwd(),
|
|
1185
|
+
recoverySource: "manual_attach",
|
|
1186
|
+
resumeTitle: recoverableCandidate?.resumeTitle
|
|
1187
|
+
});
|
|
1188
|
+
input.events?.emit({ type: "channel.current-session-changed" });
|
|
1189
|
+
return {
|
|
1190
|
+
ok: true,
|
|
1191
|
+
session: {
|
|
1192
|
+
...attached,
|
|
1193
|
+
preferredResumeMode: buildPreferredResumeMode(attached.providerId, attached.resumeTitle),
|
|
1194
|
+
preferredResumeCommand: buildPreferredResumeCommand(attached.providerId, attached.providerSessionId, attached.resumeTitle),
|
|
1195
|
+
providerResumeCommand: buildProviderResumeCommand(attached.providerId, attached.providerSessionId),
|
|
1196
|
+
providerResumeByTitleCommand: buildProviderResumeByTitleCommand(attached.providerId, attached.resumeTitle)
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
});
|
|
1200
|
+
input.app.post("/api/channel/sessions/new", async (request, reply) => {
|
|
1201
|
+
const user = input.users.isActiveUser(PRIMARY_WEIXIN_PLATFORM, request.body.platformUserId);
|
|
1202
|
+
if (!user) {
|
|
1203
|
+
return reply.code(404).send({ ok: false, error: "active_wechat_user_not_found" });
|
|
1204
|
+
}
|
|
1205
|
+
if (!input.conversation) {
|
|
1206
|
+
return reply.code(500).send({ ok: false, error: "current_conversation_store_unavailable" });
|
|
1207
|
+
}
|
|
1208
|
+
const providerId = request.body.providerId === "codex" ? "codex" : "claude-code";
|
|
1209
|
+
const cwd = typeof request.body.cwd === "string" && request.body.cwd.trim() ? request.body.cwd : input.defaults?.defaultWorkspace ?? process.cwd();
|
|
1210
|
+
const chatId = request.body.chatId ?? user.platformUserId;
|
|
1211
|
+
const session = input.conversation.create({
|
|
1212
|
+
chatId,
|
|
1213
|
+
ownerUserId: user.id,
|
|
1214
|
+
providerId,
|
|
1215
|
+
cwd
|
|
1216
|
+
});
|
|
1217
|
+
input.events?.emit({ type: "channel.current-session-changed" });
|
|
1218
|
+
const providerLabel = providerId === "codex" ? "Codex" : "Claude Code";
|
|
1219
|
+
try {
|
|
1220
|
+
await input.channel?.sendMessage({
|
|
1221
|
+
chatId,
|
|
1222
|
+
kind: "status",
|
|
1223
|
+
text: `\u5DF2\u65B0\u5EFA ${providerLabel} \u4F1A\u8BDD\uFF0C\u9879\u76EE\u76EE\u5F55\uFF1A${cwd}\u3002`
|
|
1224
|
+
});
|
|
1225
|
+
} catch (error) {
|
|
1226
|
+
request.log.warn({ err: error }, "sessions_new_notify_failed");
|
|
1227
|
+
}
|
|
1228
|
+
return {
|
|
1229
|
+
ok: true,
|
|
1230
|
+
session: {
|
|
1231
|
+
id: session.id,
|
|
1232
|
+
providerId: session.providerId,
|
|
1233
|
+
cwd: session.cwd,
|
|
1234
|
+
status: session.status
|
|
1235
|
+
}
|
|
1236
|
+
};
|
|
1237
|
+
});
|
|
1238
|
+
input.app.post("/api/channel/sessions/auto-attach", async (request, reply) => {
|
|
1239
|
+
const provider = input.providers?.find((candidate) => candidate.id === request.body.providerId);
|
|
1240
|
+
if (!provider?.attachSession || !provider.listRecoverableSessions) {
|
|
1241
|
+
return reply.code(404).send({ ok: false, error: "provider_session_attach_not_supported" });
|
|
1242
|
+
}
|
|
1243
|
+
const user = input.users.isActiveUser(PRIMARY_WEIXIN_PLATFORM, request.body.platformUserId);
|
|
1244
|
+
if (!user) {
|
|
1245
|
+
return reply.code(404).send({ ok: false, error: "active_wechat_user_not_found" });
|
|
1246
|
+
}
|
|
1247
|
+
if (!input.conversation) {
|
|
1248
|
+
return reply.code(500).send({ ok: false, error: "current_conversation_store_unavailable" });
|
|
1249
|
+
}
|
|
1250
|
+
const targetCwd = request.body.cwd ?? input.defaults?.defaultWorkspace ?? process.cwd();
|
|
1251
|
+
const selection = await selectBestRecoverableSession({
|
|
1252
|
+
provider,
|
|
1253
|
+
providerId: request.body.providerId === "codex" ? "codex" : "claude-code",
|
|
1254
|
+
targetCwd,
|
|
1255
|
+
lastProviderSessions: input.lastProviderSessions,
|
|
1256
|
+
currentSession: input.conversation.getCurrent()
|
|
1257
|
+
});
|
|
1258
|
+
if (!selection) {
|
|
1259
|
+
return reply.code(404).send({ ok: false, error: "recoverable_provider_session_not_found" });
|
|
1260
|
+
}
|
|
1261
|
+
const attached = await attachProviderSessionToBridge({
|
|
1262
|
+
conversationStore: input.conversation,
|
|
1263
|
+
lastProviderSessions: input.lastProviderSessions,
|
|
1264
|
+
provider,
|
|
1265
|
+
user,
|
|
1266
|
+
providerId: request.body.providerId === "codex" ? "codex" : "claude-code",
|
|
1267
|
+
providerSessionId: selection.candidate.id,
|
|
1268
|
+
chatId: request.body.chatId ?? user.platformUserId,
|
|
1269
|
+
cwd: request.body.cwd ?? selection.candidate.cwd ?? input.defaults?.defaultWorkspace ?? process.cwd(),
|
|
1270
|
+
recoverySource: selection.bindingSource,
|
|
1271
|
+
resumeTitle: selection.candidate.resumeTitle
|
|
1272
|
+
});
|
|
1273
|
+
return {
|
|
1274
|
+
ok: true,
|
|
1275
|
+
session: {
|
|
1276
|
+
...attached,
|
|
1277
|
+
bindingMatched: selection.matchedBinding,
|
|
1278
|
+
bindingSource: selection.bindingSource,
|
|
1279
|
+
preferredResumeMode: buildPreferredResumeMode(attached.providerId, attached.resumeTitle),
|
|
1280
|
+
preferredResumeCommand: buildPreferredResumeCommand(attached.providerId, attached.providerSessionId, attached.resumeTitle),
|
|
1281
|
+
providerResumeCommand: buildProviderResumeCommand(attached.providerId, attached.providerSessionId),
|
|
1282
|
+
providerResumeByTitleCommand: buildProviderResumeByTitleCommand(attached.providerId, attached.resumeTitle)
|
|
1283
|
+
}
|
|
1284
|
+
};
|
|
1285
|
+
});
|
|
1286
|
+
input.app.get("/api/channel/current-session", async () => {
|
|
1287
|
+
const session = input.conversation?.getCurrent();
|
|
1288
|
+
if (!session) return null;
|
|
1289
|
+
const provider = input.providers?.find((candidate) => candidate.id === session.providerId);
|
|
1290
|
+
const nativeTitle = session.providerSessionId && provider?.listRecoverableSessions ? (await provider.listRecoverableSessions().catch(() => [])).find((candidate) => candidate.id === session.providerSessionId)?.title : void 0;
|
|
1291
|
+
const providerNativePath = await resolveProviderNativePath(session.providerId, session.providerSessionId);
|
|
1292
|
+
const providerResumeTitleSynced = await resolveProviderResumeTitleSynced(session);
|
|
1293
|
+
const providerResumeHistorySynced = await resolveProviderResumeHistorySynced(session);
|
|
1294
|
+
const binding = input.lastProviderSessions?.get(session.providerId);
|
|
1295
|
+
return {
|
|
1296
|
+
...session,
|
|
1297
|
+
...nativeTitle ? { nativeTitle } : {},
|
|
1298
|
+
preferredResumeMode: buildPreferredResumeMode(session.providerId, session.resumeTitle),
|
|
1299
|
+
preferredResumeCommand: buildPreferredResumeCommand(session.providerId, session.providerSessionId, session.resumeTitle),
|
|
1300
|
+
providerResumeCommand: buildProviderResumeCommand(session.providerId, session.providerSessionId),
|
|
1301
|
+
providerResumeByTitleCommand: buildProviderResumeByTitleCommand(session.providerId, session.resumeTitle),
|
|
1302
|
+
bindingMatched: input.getSessionBindingMatch?.(session.id) === true,
|
|
1303
|
+
bindingSource: session.recoverySource,
|
|
1304
|
+
...binding ? {
|
|
1305
|
+
bindingProviderSessionId: binding.providerSessionId,
|
|
1306
|
+
bindingUpdatedAt: binding.updatedAt
|
|
1307
|
+
} : {},
|
|
1308
|
+
providerNativeReachable: Boolean(providerNativePath),
|
|
1309
|
+
providerResumeTitleSynced,
|
|
1310
|
+
providerResumeHistorySynced,
|
|
1311
|
+
providerResumeRepairable: session.providerId === "claude-code" && Boolean(session.providerSessionId && session.resumeTitle && providerNativePath),
|
|
1312
|
+
...providerNativePath ? { providerNativePath } : {}
|
|
1313
|
+
};
|
|
1314
|
+
});
|
|
1315
|
+
input.app.get("/api/channel/sessions", async () => {
|
|
1316
|
+
const session = await input.app.inject({ method: "GET", url: "/api/channel/current-session" });
|
|
1317
|
+
if (session.statusCode === 404) return [];
|
|
1318
|
+
const payload = session.json();
|
|
1319
|
+
return payload ? [payload] : [];
|
|
1320
|
+
});
|
|
1321
|
+
input.app.post("/api/channel/settings/sync", async (_request) => {
|
|
1322
|
+
input.conversation?.clear();
|
|
1323
|
+
input.events?.emit({ type: "channel.current-session-changed" });
|
|
1324
|
+
return { ok: true };
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
function toQuotaView(activeUserStore, store) {
|
|
1328
|
+
if (!store) return null;
|
|
1329
|
+
const activeUser = activeUserStore.getActiveUser();
|
|
1330
|
+
if (activeUser?.platform !== PRIMARY_WEIXIN_PLATFORM) return null;
|
|
1331
|
+
const quota = store.getQuota(activeUser.platformUserId);
|
|
1332
|
+
return {
|
|
1333
|
+
remaining: quota.remaining,
|
|
1334
|
+
sentCount: quota.sentCount,
|
|
1335
|
+
limit: PUSH_QUOTA_LIMIT,
|
|
1336
|
+
expired: quota.expired,
|
|
1337
|
+
windowEndsAt: quota.windowStartAt ? quota.windowStartAt + PUSH_WINDOW_MS : 0
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
function toWechatPluginStatus(wechat, activeUserStore, channel) {
|
|
1341
|
+
const health = channel?.getHealth?.();
|
|
1342
|
+
return {
|
|
1343
|
+
id: PRIMARY_WEIXIN_PLATFORM,
|
|
1344
|
+
type: "weixin",
|
|
1345
|
+
name: "WeChat channel",
|
|
1346
|
+
enabled: wechat?.enabled === true,
|
|
1347
|
+
connected: health ? health.connected : wechat?.enabled === true && Boolean(wechat.baseUrl),
|
|
1348
|
+
status: health ? health.status : wechat?.enabled === true ? "configured" : "disabled",
|
|
1349
|
+
...health?.lastError ? { lastError: health.lastError } : {},
|
|
1350
|
+
activeUsers: activeUserStore.getActiveUser()?.platform === PRIMARY_WEIXIN_PLATFORM ? 1 : 0,
|
|
1351
|
+
hasToken: Boolean(wechat?.token),
|
|
1352
|
+
botUsername: wechat?.accountId
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
function buildProviderResumeCommand(providerId, providerSessionId) {
|
|
1356
|
+
if (!providerSessionId) return void 0;
|
|
1357
|
+
if (providerId === "claude-code") return `claude --resume ${providerSessionId}`;
|
|
1358
|
+
if (providerId === "codex") return `codex resume ${providerSessionId}`;
|
|
1359
|
+
return void 0;
|
|
1360
|
+
}
|
|
1361
|
+
function buildProviderResumeByTitleCommand(providerId, title) {
|
|
1362
|
+
if (!title) return void 0;
|
|
1363
|
+
if (providerId === "claude-code") return `claude -r ${title}`;
|
|
1364
|
+
if (providerId === "codex") return `codex resume ${title}`;
|
|
1365
|
+
return void 0;
|
|
1366
|
+
}
|
|
1367
|
+
function buildPreferredResumeCommand(providerId, providerSessionId, title) {
|
|
1368
|
+
return buildProviderResumeByTitleCommand(providerId, title) ?? buildProviderResumeCommand(providerId, providerSessionId);
|
|
1369
|
+
}
|
|
1370
|
+
function buildPreferredResumeMode(providerId, title) {
|
|
1371
|
+
return buildProviderResumeByTitleCommand(providerId, title) ? "title" : "id";
|
|
1372
|
+
}
|
|
1373
|
+
async function resolveProviderNativePath(providerId, providerSessionId) {
|
|
1374
|
+
if (!providerSessionId) return null;
|
|
1375
|
+
if (providerId === "claude-code") return await findRecoverableClaudeSessionPath(providerSessionId);
|
|
1376
|
+
if (providerId === "codex") return await findRecoverableCodexSessionPath(providerSessionId);
|
|
1377
|
+
return null;
|
|
1378
|
+
}
|
|
1379
|
+
async function resolveProviderResumeTitleSynced(session) {
|
|
1380
|
+
if (session.providerId !== "claude-code" || !session.providerSessionId || !session.resumeTitle) return void 0;
|
|
1381
|
+
return await hasClaudeSessionBridgeMetadata({
|
|
1382
|
+
sessionId: session.providerSessionId,
|
|
1383
|
+
resumeTitle: session.resumeTitle
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
async function resolveRecoverableProviderResumeTitleSynced(session) {
|
|
1387
|
+
if (session.providerId !== "claude-code" || !session.resumeTitle) return void 0;
|
|
1388
|
+
return await hasClaudeSessionBridgeMetadata({
|
|
1389
|
+
sessionId: session.id,
|
|
1390
|
+
resumeTitle: session.resumeTitle
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
async function resolveProviderResumeHistorySynced(session) {
|
|
1394
|
+
if (session.providerId !== "claude-code" || !session.providerSessionId || !session.resumeTitle) return void 0;
|
|
1395
|
+
return await hasClaudeHistoryDisplay({
|
|
1396
|
+
sessionId: session.providerSessionId,
|
|
1397
|
+
resumeTitle: session.resumeTitle
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
async function resolveRecoverableProviderResumeHistorySynced(session) {
|
|
1401
|
+
if (session.providerId !== "claude-code" || !session.resumeTitle) return void 0;
|
|
1402
|
+
return await hasClaudeHistoryDisplay({
|
|
1403
|
+
sessionId: session.id,
|
|
1404
|
+
resumeTitle: session.resumeTitle
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// src/admin/settingsRoutes.ts
|
|
1409
|
+
function registerSettingsRoutes(input) {
|
|
1410
|
+
input.app.get("/api/settings", async () => input.defaults);
|
|
1411
|
+
input.app.post("/api/settings", async (request) => {
|
|
1412
|
+
const current = { ...input.defaults };
|
|
1413
|
+
const next = normalizeSettings({
|
|
1414
|
+
...current,
|
|
1415
|
+
...request.body
|
|
1416
|
+
}, current.defaultWorkspace);
|
|
1417
|
+
input.defaults.defaultProvider = next.defaultProvider;
|
|
1418
|
+
input.defaults.defaultWorkspace = next.defaultWorkspace;
|
|
1419
|
+
await persistBridgeDefaultsToConfigFile({
|
|
1420
|
+
configPath: input.configPath,
|
|
1421
|
+
defaultProvider: next.defaultProvider,
|
|
1422
|
+
defaultWorkspace: next.defaultWorkspace
|
|
1423
|
+
});
|
|
1424
|
+
return { ok: true };
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
function normalizeSettings(input, defaultWorkspace) {
|
|
1428
|
+
return {
|
|
1429
|
+
defaultProvider: input.defaultProvider === "codex" ? "codex" : "claude-code",
|
|
1430
|
+
defaultWorkspace: typeof input.defaultWorkspace === "string" && input.defaultWorkspace.trim() ? input.defaultWorkspace : defaultWorkspace
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// src/channels/weixin-direct/apiClient.ts
|
|
1435
|
+
import { randomUUID } from "node:crypto";
|
|
1436
|
+
function collectInboundItems(itemList) {
|
|
1437
|
+
const texts = [];
|
|
1438
|
+
const attachments = [];
|
|
1439
|
+
let quoted;
|
|
1440
|
+
for (const item of itemList ?? []) {
|
|
1441
|
+
if (item.type === 1 && item.text_item?.text?.trim()) texts.push(item.text_item.text.trim());
|
|
1442
|
+
else if (item.type === 3 && item.voice_item?.text?.trim()) texts.push(item.voice_item.text.trim());
|
|
1443
|
+
else if (item.type === 2 && item.image_item) attachments.push({ kind: "image", media: item.image_item.media, aeskey: item.image_item.aeskey });
|
|
1444
|
+
else if (item.type === 4 && item.file_item) attachments.push({ kind: "file", media: item.file_item.media, fileName: item.file_item.file_name });
|
|
1445
|
+
else if (item.type === 5 && item.video_item) attachments.push({ kind: "video", media: item.video_item.media });
|
|
1446
|
+
if (item.ref_msg?.message_item) {
|
|
1447
|
+
const inner = collectInboundItems([item.ref_msg.message_item]);
|
|
1448
|
+
const text = [item.ref_msg.title, ...inner.texts].filter(Boolean).join(" ") || void 0;
|
|
1449
|
+
quoted = {
|
|
1450
|
+
...text ? { text } : {},
|
|
1451
|
+
...inner.attachments.length ? { attachments: inner.attachments } : {}
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return { texts, attachments, quoted };
|
|
1456
|
+
}
|
|
1457
|
+
var WeixinDirectApiClient = class {
|
|
1458
|
+
baseUrl;
|
|
1459
|
+
botToken;
|
|
1460
|
+
wechatUin;
|
|
1461
|
+
channelVersion;
|
|
1462
|
+
fetchImpl;
|
|
1463
|
+
constructor(input) {
|
|
1464
|
+
this.baseUrl = input.baseUrl.replace(/\/+$/, "");
|
|
1465
|
+
this.botToken = input.botToken;
|
|
1466
|
+
this.wechatUin = input.wechatUin;
|
|
1467
|
+
this.channelVersion = input.channelVersion ?? "0.1.0";
|
|
1468
|
+
this.fetchImpl = input.fetchImpl ?? fetch;
|
|
1469
|
+
}
|
|
1470
|
+
async sendTextMessage(input) {
|
|
1471
|
+
const response = await this.fetchImpl(`${this.baseUrl}/ilink/bot/sendmessage`, {
|
|
1472
|
+
method: "POST",
|
|
1473
|
+
headers: {
|
|
1474
|
+
"content-type": "application/json",
|
|
1475
|
+
AuthorizationType: "ilink_bot_token",
|
|
1476
|
+
Authorization: `Bearer ${this.botToken}`,
|
|
1477
|
+
"X-WECHAT-UIN": this.wechatUin
|
|
1478
|
+
},
|
|
1479
|
+
body: JSON.stringify({
|
|
1480
|
+
msg: {
|
|
1481
|
+
to_user_id: input.toUserId,
|
|
1482
|
+
client_id: randomUUID(),
|
|
1483
|
+
message_type: 2,
|
|
1484
|
+
message_state: 2,
|
|
1485
|
+
item_list: [
|
|
1486
|
+
{
|
|
1487
|
+
type: 1,
|
|
1488
|
+
text_item: {
|
|
1489
|
+
text: input.text
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
],
|
|
1493
|
+
...input.contextToken ? { context_token: input.contextToken } : {}
|
|
1494
|
+
},
|
|
1495
|
+
base_info: { channel_version: this.channelVersion }
|
|
1496
|
+
})
|
|
1497
|
+
});
|
|
1498
|
+
if (!response.ok) {
|
|
1499
|
+
throw new Error(`weixin_send_message_failed:${response.status}`);
|
|
1500
|
+
}
|
|
1501
|
+
const payload = await response.json();
|
|
1502
|
+
if ((payload.errcode ?? 0) !== 0 || (payload.ret ?? 0) !== 0) {
|
|
1503
|
+
throw new Error(`weixin_send_message_failed:${payload.errcode ?? payload.ret ?? -1}:${payload.errmsg ?? "unknown_error"}`);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
async getUpdates(buffer, signal) {
|
|
1507
|
+
const response = await this.fetchImpl(`${this.baseUrl}/ilink/bot/getupdates`, {
|
|
1508
|
+
method: "POST",
|
|
1509
|
+
headers: {
|
|
1510
|
+
"content-type": "application/json",
|
|
1511
|
+
AuthorizationType: "ilink_bot_token",
|
|
1512
|
+
Authorization: `Bearer ${this.botToken}`,
|
|
1513
|
+
"X-WECHAT-UIN": this.wechatUin
|
|
1514
|
+
},
|
|
1515
|
+
body: JSON.stringify({
|
|
1516
|
+
get_updates_buf: buffer,
|
|
1517
|
+
base_info: { channel_version: this.channelVersion }
|
|
1518
|
+
}),
|
|
1519
|
+
...signal ? { signal } : {}
|
|
1520
|
+
});
|
|
1521
|
+
if (!response.ok) {
|
|
1522
|
+
throw new Error(`weixin_get_updates_failed:${response.status}`);
|
|
1523
|
+
}
|
|
1524
|
+
const payload = await response.json();
|
|
1525
|
+
if ((payload.errcode ?? 0) !== 0 || (payload.ret ?? 0) !== 0) {
|
|
1526
|
+
throw new Error(`weixin_get_updates_failed:${payload.errcode ?? payload.ret ?? -1}:${payload.errmsg ?? "unknown_error"}`);
|
|
1527
|
+
}
|
|
1528
|
+
const messages = (payload.msgs ?? []).map((message) => {
|
|
1529
|
+
if (!message.from_user_id) return null;
|
|
1530
|
+
const { texts, attachments, quoted } = collectInboundItems(message.item_list);
|
|
1531
|
+
const text = texts.join("\n\n");
|
|
1532
|
+
if (!text && attachments.length === 0 && !quoted) return null;
|
|
1533
|
+
return {
|
|
1534
|
+
id: message.msg_id ?? "",
|
|
1535
|
+
chatId: message.from_user_id,
|
|
1536
|
+
userId: message.from_user_id,
|
|
1537
|
+
text,
|
|
1538
|
+
...message.context_token ? { contextToken: message.context_token } : {},
|
|
1539
|
+
...attachments.length ? { attachments } : {},
|
|
1540
|
+
...quoted ? { quoted } : {}
|
|
1541
|
+
};
|
|
1542
|
+
}).filter((value) => value !== null);
|
|
1543
|
+
return {
|
|
1544
|
+
nextBuffer: payload.get_updates_buf ?? "",
|
|
1545
|
+
messages
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
async getConfig(input) {
|
|
1549
|
+
const response = await this.fetchImpl(`${this.baseUrl}/ilink/bot/getconfig`, {
|
|
1550
|
+
method: "POST",
|
|
1551
|
+
headers: {
|
|
1552
|
+
"content-type": "application/json",
|
|
1553
|
+
AuthorizationType: "ilink_bot_token",
|
|
1554
|
+
Authorization: `Bearer ${this.botToken}`,
|
|
1555
|
+
"X-WECHAT-UIN": this.wechatUin
|
|
1556
|
+
},
|
|
1557
|
+
body: JSON.stringify({
|
|
1558
|
+
ilink_user_id: input.ilinkUserId,
|
|
1559
|
+
...input.contextToken ? { context_token: input.contextToken } : {},
|
|
1560
|
+
base_info: { channel_version: this.channelVersion }
|
|
1561
|
+
})
|
|
1562
|
+
});
|
|
1563
|
+
if (!response.ok) {
|
|
1564
|
+
throw new Error(`weixin_get_config_failed:${response.status}`);
|
|
1565
|
+
}
|
|
1566
|
+
const raw = await response.text();
|
|
1567
|
+
const payload = JSON.parse(raw);
|
|
1568
|
+
if ((payload.ret ?? 0) !== 0) {
|
|
1569
|
+
throw new Error(`weixin_get_config_failed:${payload.ret ?? -1}:${payload.errmsg ?? "unknown_error"}`);
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
typingTicket: payload.typing_ticket?.trim() ?? ""
|
|
1573
|
+
};
|
|
1574
|
+
}
|
|
1575
|
+
async sendTyping(input) {
|
|
1576
|
+
const response = await this.fetchImpl(`${this.baseUrl}/ilink/bot/sendtyping`, {
|
|
1577
|
+
method: "POST",
|
|
1578
|
+
headers: {
|
|
1579
|
+
"content-type": "application/json",
|
|
1580
|
+
AuthorizationType: "ilink_bot_token",
|
|
1581
|
+
Authorization: `Bearer ${this.botToken}`,
|
|
1582
|
+
"X-WECHAT-UIN": this.wechatUin
|
|
1583
|
+
},
|
|
1584
|
+
body: JSON.stringify({
|
|
1585
|
+
ilink_user_id: input.ilinkUserId,
|
|
1586
|
+
typing_ticket: input.typingTicket,
|
|
1587
|
+
status: input.status,
|
|
1588
|
+
base_info: { channel_version: this.channelVersion }
|
|
1589
|
+
})
|
|
1590
|
+
});
|
|
1591
|
+
if (!response.ok) {
|
|
1592
|
+
throw new Error(`weixin_send_typing_failed:${response.status}`);
|
|
1593
|
+
}
|
|
1594
|
+
const payload = await response.json();
|
|
1595
|
+
if ((payload.ret ?? 0) !== 0) {
|
|
1596
|
+
throw new Error(`weixin_send_typing_failed:${payload.ret ?? -1}:${payload.errmsg ?? "unknown_error"}`);
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
};
|
|
1600
|
+
|
|
1601
|
+
// src/channels/weixin-direct/adapter.ts
|
|
1602
|
+
import { setTimeout as delay2 } from "node:timers/promises";
|
|
1603
|
+
import { join as join6 } from "node:path";
|
|
1604
|
+
|
|
1605
|
+
// src/channels/weixin-direct/typingController.ts
|
|
1606
|
+
var CONFIG_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
1607
|
+
var CONFIG_CACHE_INITIAL_RETRY_MS = 2e3;
|
|
1608
|
+
var CONFIG_CACHE_MAX_RETRY_MS = 60 * 60 * 1e3;
|
|
1609
|
+
var TypingController = class {
|
|
1610
|
+
constructor(deps) {
|
|
1611
|
+
this.deps = deps;
|
|
1612
|
+
}
|
|
1613
|
+
deps;
|
|
1614
|
+
tickets = /* @__PURE__ */ new Map();
|
|
1615
|
+
chain = /* @__PURE__ */ new Map();
|
|
1616
|
+
disposed = false;
|
|
1617
|
+
set(chatId, active) {
|
|
1618
|
+
if (this.disposed) return Promise.resolve();
|
|
1619
|
+
const previous = this.chain.get(chatId) ?? Promise.resolve();
|
|
1620
|
+
const next = previous.then(
|
|
1621
|
+
() => this.attempt(chatId, active),
|
|
1622
|
+
() => this.attempt(chatId, active)
|
|
1623
|
+
);
|
|
1624
|
+
this.chain.set(chatId, next);
|
|
1625
|
+
next.finally(() => {
|
|
1626
|
+
if (this.chain.get(chatId) === next) this.chain.delete(chatId);
|
|
1627
|
+
});
|
|
1628
|
+
return next;
|
|
1629
|
+
}
|
|
1630
|
+
dispose() {
|
|
1631
|
+
this.disposed = true;
|
|
1632
|
+
this.chain.clear();
|
|
1633
|
+
}
|
|
1634
|
+
async attempt(chatId, active) {
|
|
1635
|
+
if (this.disposed) return;
|
|
1636
|
+
const ticket = await this.getTicket(chatId);
|
|
1637
|
+
if (!ticket || this.disposed) return;
|
|
1638
|
+
try {
|
|
1639
|
+
await this.deps.sendTyping({ ilinkUserId: chatId, typingTicket: ticket, status: active ? 1 : 2 });
|
|
1640
|
+
} catch (err) {
|
|
1641
|
+
this.deps.log?.(`typing send failed (status=${active ? 1 : 2}): ${String(err)}`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
async getTicket(chatId) {
|
|
1645
|
+
const now = Date.now();
|
|
1646
|
+
const entry = this.tickets.get(chatId);
|
|
1647
|
+
if (entry && now < entry.nextFetchAt) return entry.ticket;
|
|
1648
|
+
try {
|
|
1649
|
+
const config = await this.deps.getConfig({
|
|
1650
|
+
ilinkUserId: chatId,
|
|
1651
|
+
contextToken: this.deps.getContextToken(chatId)
|
|
1652
|
+
});
|
|
1653
|
+
const ticket = config.typingTicket.trim();
|
|
1654
|
+
this.tickets.set(chatId, {
|
|
1655
|
+
ticket,
|
|
1656
|
+
nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
|
|
1657
|
+
retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS
|
|
1658
|
+
});
|
|
1659
|
+
return ticket;
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
this.deps.log?.(`getConfig failed for ${chatId}: ${String(err)}`);
|
|
1662
|
+
if (entry) {
|
|
1663
|
+
entry.retryDelayMs = Math.min(entry.retryDelayMs * 2, CONFIG_CACHE_MAX_RETRY_MS);
|
|
1664
|
+
entry.nextFetchAt = now + entry.retryDelayMs;
|
|
1665
|
+
return entry.ticket;
|
|
1666
|
+
}
|
|
1667
|
+
this.tickets.set(chatId, {
|
|
1668
|
+
ticket: "",
|
|
1669
|
+
nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,
|
|
1670
|
+
retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS
|
|
1671
|
+
});
|
|
1672
|
+
return "";
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
};
|
|
1676
|
+
|
|
1677
|
+
// src/channels/weixin-direct/adapter.ts
|
|
1678
|
+
var MAX_TEXT_LENGTH = 4e3;
|
|
1679
|
+
var DEFAULT_CHUNK_DELAY_MS = 300;
|
|
1680
|
+
var DEDUP_WINDOW_MS = 5 * 6e4;
|
|
1681
|
+
var MAX_RETRY_DELAY_MS = 3e4;
|
|
1682
|
+
var VIDEO_MAX_BYTES = 25 * 1024 * 1024;
|
|
1683
|
+
var WeixinDirectAdapter = class _WeixinDirectAdapter {
|
|
1684
|
+
constructor(options) {
|
|
1685
|
+
this.options = options;
|
|
1686
|
+
const { api } = options;
|
|
1687
|
+
this.typing = api.getConfig && api.sendTyping ? new TypingController({
|
|
1688
|
+
getConfig: (input) => api.getConfig(input),
|
|
1689
|
+
sendTyping: (input) => api.sendTyping(input),
|
|
1690
|
+
getContextToken: (chatId) => this.contextTokens.get(chatId),
|
|
1691
|
+
log: (msg) => console.warn("[weixin][typing]", msg)
|
|
1692
|
+
}) : null;
|
|
1693
|
+
}
|
|
1694
|
+
options;
|
|
1695
|
+
id = "weixin-direct";
|
|
1696
|
+
static SESSION_TIMEOUT_THRESHOLD = 3;
|
|
1697
|
+
handler = null;
|
|
1698
|
+
stopped = true;
|
|
1699
|
+
buffer = "";
|
|
1700
|
+
contextTokens = /* @__PURE__ */ new Map();
|
|
1701
|
+
seenMessageIds = /* @__PURE__ */ new Map();
|
|
1702
|
+
retryDelayMs = 0;
|
|
1703
|
+
typing;
|
|
1704
|
+
runningTask = null;
|
|
1705
|
+
pollAbort = null;
|
|
1706
|
+
healthy = false;
|
|
1707
|
+
lastError = null;
|
|
1708
|
+
consecutiveSessionTimeouts = 0;
|
|
1709
|
+
healthListeners = /* @__PURE__ */ new Set();
|
|
1710
|
+
lastNotifiedStatus = null;
|
|
1711
|
+
onMessage(handler) {
|
|
1712
|
+
this.handler = handler;
|
|
1713
|
+
}
|
|
1714
|
+
onHealthChange(listener) {
|
|
1715
|
+
this.healthListeners.add(listener);
|
|
1716
|
+
}
|
|
1717
|
+
notifyHealthChange() {
|
|
1718
|
+
const status = this.getHealth().status;
|
|
1719
|
+
if (status === this.lastNotifiedStatus) return;
|
|
1720
|
+
this.lastNotifiedStatus = status;
|
|
1721
|
+
for (const listener of this.healthListeners) listener();
|
|
1722
|
+
}
|
|
1723
|
+
async start(input) {
|
|
1724
|
+
if (!this.handler) throw new Error("weixin_direct_handler_not_registered");
|
|
1725
|
+
if (this.options.stateStore) {
|
|
1726
|
+
const persisted = this.options.stateStore.load();
|
|
1727
|
+
for (const [userId, token] of Object.entries(persisted.contextTokens)) {
|
|
1728
|
+
this.contextTokens.set(userId, token);
|
|
1729
|
+
}
|
|
1730
|
+
if (persisted.cursor) this.buffer = persisted.cursor;
|
|
1731
|
+
}
|
|
1732
|
+
this.stopped = false;
|
|
1733
|
+
const loop = this.runLoop();
|
|
1734
|
+
this.runningTask = loop;
|
|
1735
|
+
if (input?.background) return;
|
|
1736
|
+
await loop;
|
|
1737
|
+
}
|
|
1738
|
+
async stop() {
|
|
1739
|
+
this.stopped = true;
|
|
1740
|
+
this.pollAbort?.abort();
|
|
1741
|
+
await this.runningTask;
|
|
1742
|
+
this.runningTask = null;
|
|
1743
|
+
this.typing?.dispose();
|
|
1744
|
+
this.notifyHealthChange();
|
|
1745
|
+
}
|
|
1746
|
+
async sendMessage(message) {
|
|
1747
|
+
const contextToken = this.contextTokens.get(message.chatId);
|
|
1748
|
+
if (!contextToken) {
|
|
1749
|
+
throw new Error(`weixin_no_context_token:${message.chatId}`);
|
|
1750
|
+
}
|
|
1751
|
+
if (this.options.stateStore && !this.options.stateStore.canSend(message.chatId)) {
|
|
1752
|
+
throw new Error(`weixin_push_quota_exceeded:${message.chatId}`);
|
|
1753
|
+
}
|
|
1754
|
+
const chunks = chunkText(message.text ?? "", MAX_TEXT_LENGTH);
|
|
1755
|
+
const chunkDelayMs = this.options.chunkDelayMs ?? DEFAULT_CHUNK_DELAY_MS;
|
|
1756
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
1757
|
+
if (i > 0 && chunkDelayMs > 0) await delay2(chunkDelayMs);
|
|
1758
|
+
await this.options.api.sendTextMessage({
|
|
1759
|
+
toUserId: message.chatId,
|
|
1760
|
+
text: chunks[i],
|
|
1761
|
+
contextToken
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
this.options.stateStore?.recordSent(message.chatId);
|
|
1765
|
+
}
|
|
1766
|
+
async setTyping(chatId, active) {
|
|
1767
|
+
await this.typing?.set(chatId, active);
|
|
1768
|
+
}
|
|
1769
|
+
getHealth() {
|
|
1770
|
+
if (this.lastError) {
|
|
1771
|
+
const status = this.consecutiveSessionTimeouts >= _WeixinDirectAdapter.SESSION_TIMEOUT_THRESHOLD ? "session_timeout" : "poll_error";
|
|
1772
|
+
return {
|
|
1773
|
+
connected: false,
|
|
1774
|
+
status,
|
|
1775
|
+
lastError: this.lastError
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
if (this.healthy) return { connected: true, status: "connected" };
|
|
1779
|
+
if (this.stopped) return { connected: false, status: "stopped" };
|
|
1780
|
+
return { connected: false, status: "connecting" };
|
|
1781
|
+
}
|
|
1782
|
+
async runLoop() {
|
|
1783
|
+
while (!this.stopped) {
|
|
1784
|
+
let updates;
|
|
1785
|
+
try {
|
|
1786
|
+
this.pollAbort = new AbortController();
|
|
1787
|
+
if (!this.healthy && !this.lastError) {
|
|
1788
|
+
this.healthy = true;
|
|
1789
|
+
this.notifyHealthChange();
|
|
1790
|
+
}
|
|
1791
|
+
updates = await this.options.api.getUpdates(this.buffer, this.pollAbort.signal);
|
|
1792
|
+
this.healthy = true;
|
|
1793
|
+
this.lastError = null;
|
|
1794
|
+
this.consecutiveSessionTimeouts = 0;
|
|
1795
|
+
this.retryDelayMs = 0;
|
|
1796
|
+
this.notifyHealthChange();
|
|
1797
|
+
} catch (error) {
|
|
1798
|
+
if (this.stopped) break;
|
|
1799
|
+
this.healthy = false;
|
|
1800
|
+
this.lastError = error instanceof Error ? error.message : String(error);
|
|
1801
|
+
if (this.lastError.includes("session timeout")) this.consecutiveSessionTimeouts += 1;
|
|
1802
|
+
else this.consecutiveSessionTimeouts = 0;
|
|
1803
|
+
this.notifyHealthChange();
|
|
1804
|
+
const base = this.options.pollIntervalMs ?? 1e3;
|
|
1805
|
+
this.retryDelayMs = this.retryDelayMs === 0 ? base : Math.min(this.retryDelayMs * 2, MAX_RETRY_DELAY_MS);
|
|
1806
|
+
await delay2(this.retryDelayMs);
|
|
1807
|
+
continue;
|
|
1808
|
+
}
|
|
1809
|
+
this.buffer = updates.nextBuffer;
|
|
1810
|
+
this.options.stateStore?.setCursor(this.buffer);
|
|
1811
|
+
for (const message of updates.messages) {
|
|
1812
|
+
if (this.isDuplicate(message.id)) continue;
|
|
1813
|
+
if (message.contextToken) {
|
|
1814
|
+
this.contextTokens.set(message.chatId, message.contextToken);
|
|
1815
|
+
this.options.stateStore?.setContextToken(message.chatId, message.contextToken);
|
|
1816
|
+
}
|
|
1817
|
+
const inbound = message;
|
|
1818
|
+
void (async () => {
|
|
1819
|
+
const content = await this.buildIncomingContent(inbound);
|
|
1820
|
+
await this.handler?.({
|
|
1821
|
+
id: inbound.id,
|
|
1822
|
+
platform: PRIMARY_WEIXIN_PLATFORM,
|
|
1823
|
+
chatId: inbound.chatId,
|
|
1824
|
+
user: { id: inbound.userId },
|
|
1825
|
+
content,
|
|
1826
|
+
timestamp: Date.now(),
|
|
1827
|
+
raw: inbound
|
|
1828
|
+
});
|
|
1829
|
+
})().catch((error) => {
|
|
1830
|
+
console.error("[weixin] message handler failed:", error);
|
|
1831
|
+
});
|
|
1832
|
+
if (this.stopped) break;
|
|
1833
|
+
}
|
|
1834
|
+
if (this.stopped) break;
|
|
1835
|
+
await delay2(this.options.pollIntervalMs ?? 1e3);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
async buildIncomingContent(message) {
|
|
1839
|
+
const attachments = await this.downloadAttachments(message.attachments, message.id);
|
|
1840
|
+
let quoted;
|
|
1841
|
+
if (message.quoted) {
|
|
1842
|
+
const quotedAttachments = await this.downloadAttachments(message.quoted.attachments, `${message.id}_q`);
|
|
1843
|
+
quoted = {
|
|
1844
|
+
...message.quoted.text ? { text: message.quoted.text } : {},
|
|
1845
|
+
...quotedAttachments.length ? { attachments: quotedAttachments } : {}
|
|
1846
|
+
};
|
|
1847
|
+
}
|
|
1848
|
+
const type = attachments.length === 0 ? "text" : message.text ? "mixed" : attachments[0]?.kind ?? "mixed";
|
|
1849
|
+
return {
|
|
1850
|
+
type,
|
|
1851
|
+
...message.text ? { text: message.text } : {},
|
|
1852
|
+
...attachments.length ? { attachments } : {},
|
|
1853
|
+
...quoted ? { quoted } : {}
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
async downloadAttachments(metas, idPrefix) {
|
|
1857
|
+
if (!metas?.length) return [];
|
|
1858
|
+
const downloader = this.options.mediaDownloader;
|
|
1859
|
+
const mediaDir = this.options.mediaDir;
|
|
1860
|
+
const out = [];
|
|
1861
|
+
for (let i = 0; i < metas.length; i += 1) {
|
|
1862
|
+
const meta = metas[i];
|
|
1863
|
+
if (!downloader || !mediaDir) {
|
|
1864
|
+
out.push({ kind: meta.kind, ...meta.fileName ? { fileName: meta.fileName } : {}, failed: true, failReason: "downloader_unavailable" });
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
const { ext, mimeType } = mediaExtAndMime(meta);
|
|
1868
|
+
const destPath = join6(mediaDir, `${idPrefix}_${i}${ext}`);
|
|
1869
|
+
const maxBytes = meta.kind === "video" ? VIDEO_MAX_BYTES : void 0;
|
|
1870
|
+
const result = await downloader.download(meta.media ?? {}, { destPath, aeskeyOverride: meta.aeskey, maxBytes });
|
|
1871
|
+
if (result.ok) {
|
|
1872
|
+
out.push({ kind: meta.kind, localPath: result.localPath, ...meta.fileName ? { fileName: meta.fileName } : {}, ...mimeType ? { mimeType } : {} });
|
|
1873
|
+
} else {
|
|
1874
|
+
out.push({ kind: meta.kind, ...meta.fileName ? { fileName: meta.fileName } : {}, failed: true, failReason: result.reason });
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
return out;
|
|
1878
|
+
}
|
|
1879
|
+
isDuplicate(id) {
|
|
1880
|
+
if (!id) return false;
|
|
1881
|
+
const now = Date.now();
|
|
1882
|
+
for (const [seenId, ts] of this.seenMessageIds) {
|
|
1883
|
+
if (now - ts > DEDUP_WINDOW_MS) this.seenMessageIds.delete(seenId);
|
|
1884
|
+
}
|
|
1885
|
+
if (this.seenMessageIds.has(id)) return true;
|
|
1886
|
+
this.seenMessageIds.set(id, now);
|
|
1887
|
+
return false;
|
|
1888
|
+
}
|
|
1889
|
+
};
|
|
1890
|
+
function mediaExtAndMime(meta) {
|
|
1891
|
+
if (meta.fileName) {
|
|
1892
|
+
const dot = meta.fileName.lastIndexOf(".");
|
|
1893
|
+
if (dot > 0) return { ext: meta.fileName.slice(dot) };
|
|
1894
|
+
}
|
|
1895
|
+
if (meta.kind === "image") return { ext: ".jpg", mimeType: "image/jpeg" };
|
|
1896
|
+
if (meta.kind === "video") return { ext: ".mp4", mimeType: "video/mp4" };
|
|
1897
|
+
return { ext: ".bin" };
|
|
1898
|
+
}
|
|
1899
|
+
function chunkText(text, limit) {
|
|
1900
|
+
if (text.length <= limit) return [text];
|
|
1901
|
+
const chunks = [];
|
|
1902
|
+
let remaining = text;
|
|
1903
|
+
while (remaining.length > 0) {
|
|
1904
|
+
if (remaining.length <= limit) {
|
|
1905
|
+
chunks.push(remaining);
|
|
1906
|
+
break;
|
|
1907
|
+
}
|
|
1908
|
+
let splitAt = -1;
|
|
1909
|
+
const win = remaining.slice(0, limit);
|
|
1910
|
+
const para = win.lastIndexOf("\n\n");
|
|
1911
|
+
if (para > limit * 0.3) splitAt = para + 2;
|
|
1912
|
+
if (splitAt === -1) {
|
|
1913
|
+
const line = win.lastIndexOf("\n");
|
|
1914
|
+
if (line > limit * 0.3) splitAt = line + 1;
|
|
1915
|
+
}
|
|
1916
|
+
if (splitAt === -1) {
|
|
1917
|
+
const space = win.lastIndexOf(" ");
|
|
1918
|
+
if (space > limit * 0.3) splitAt = space + 1;
|
|
1919
|
+
}
|
|
1920
|
+
if (splitAt === -1) splitAt = limit;
|
|
1921
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
1922
|
+
remaining = remaining.slice(splitAt);
|
|
1923
|
+
}
|
|
1924
|
+
return chunks.length > 0 ? chunks : [""];
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
// src/channels/weixin-direct/mediaDownloader.ts
|
|
1928
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1929
|
+
import { dirname as dirname5 } from "node:path";
|
|
1930
|
+
|
|
1931
|
+
// src/channels/weixin-direct/mediaCrypto.ts
|
|
1932
|
+
import { createDecipheriv } from "node:crypto";
|
|
1933
|
+
function decodeAesKey(encoded) {
|
|
1934
|
+
if (/^[0-9a-fA-F]{32}$/.test(encoded)) {
|
|
1935
|
+
return Buffer.from(encoded, "hex");
|
|
1936
|
+
}
|
|
1937
|
+
const decoded = Buffer.from(encoded, "base64");
|
|
1938
|
+
if (decoded.length === 16) {
|
|
1939
|
+
return decoded;
|
|
1940
|
+
}
|
|
1941
|
+
if (decoded.length === 32) {
|
|
1942
|
+
const hex = decoded.toString("ascii");
|
|
1943
|
+
if (/^[0-9a-fA-F]{32}$/.test(hex)) {
|
|
1944
|
+
return Buffer.from(hex, "hex");
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
throw new Error(`weixin_media_aes_key_invalid:len=${decoded.length}`);
|
|
1948
|
+
}
|
|
1949
|
+
function decryptAesEcb(ciphertext, key) {
|
|
1950
|
+
if (key.length !== 16) {
|
|
1951
|
+
throw new Error(`weixin_media_aes_key_size:${key.length}`);
|
|
1952
|
+
}
|
|
1953
|
+
const decipher = createDecipheriv("aes-128-ecb", key, null);
|
|
1954
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// src/channels/weixin-direct/mediaDownloader.ts
|
|
1958
|
+
var CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
1959
|
+
var WeixinMediaDownloader = class {
|
|
1960
|
+
cdnBaseUrl;
|
|
1961
|
+
fetchImpl;
|
|
1962
|
+
constructor(options = {}) {
|
|
1963
|
+
this.cdnBaseUrl = (options.cdnBaseUrl ?? CDN_BASE_URL).replace(/\/+$/, "");
|
|
1964
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
1965
|
+
}
|
|
1966
|
+
async download(media, input) {
|
|
1967
|
+
const url = media.full_url?.trim() || (media.encrypt_query_param ? `${this.cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(media.encrypt_query_param)}` : "");
|
|
1968
|
+
if (!url) return { ok: false, reason: "no_url" };
|
|
1969
|
+
const keySource = input.aeskeyOverride ?? media.aes_key;
|
|
1970
|
+
if (!keySource) return { ok: false, reason: "no_key" };
|
|
1971
|
+
let response;
|
|
1972
|
+
try {
|
|
1973
|
+
response = await this.fetchImpl(url, { signal: AbortSignal.timeout(6e4) });
|
|
1974
|
+
} catch {
|
|
1975
|
+
return { ok: false, reason: "fetch_failed" };
|
|
1976
|
+
}
|
|
1977
|
+
if (!response.ok) return { ok: false, reason: `http_${response.status}` };
|
|
1978
|
+
const declaredLen = Number(response.headers.get("content-length") ?? "");
|
|
1979
|
+
if (input.maxBytes && Number.isFinite(declaredLen) && declaredLen > input.maxBytes) {
|
|
1980
|
+
return { ok: false, reason: "too_large" };
|
|
1981
|
+
}
|
|
1982
|
+
const ciphertext = Buffer.from(await response.arrayBuffer());
|
|
1983
|
+
if (input.maxBytes && ciphertext.length > input.maxBytes) {
|
|
1984
|
+
return { ok: false, reason: "too_large" };
|
|
1985
|
+
}
|
|
1986
|
+
let plaintext;
|
|
1987
|
+
try {
|
|
1988
|
+
plaintext = decryptAesEcb(ciphertext, decodeAesKey(keySource));
|
|
1989
|
+
} catch {
|
|
1990
|
+
return { ok: false, reason: "decrypt_failed" };
|
|
1991
|
+
}
|
|
1992
|
+
mkdirSync2(dirname5(input.destPath), { recursive: true });
|
|
1993
|
+
writeFileSync2(input.destPath, plaintext);
|
|
1994
|
+
return { ok: true, localPath: input.destPath, bytes: plaintext.length };
|
|
1995
|
+
}
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1998
|
+
// src/channels/weixin-direct/managedAdapter.ts
|
|
1999
|
+
var ManagedWeixinDirectAdapter = class {
|
|
2000
|
+
id = "weixin-managed";
|
|
2001
|
+
handler = null;
|
|
2002
|
+
adapter = null;
|
|
2003
|
+
running = false;
|
|
2004
|
+
operation = Promise.resolve();
|
|
2005
|
+
healthListeners = /* @__PURE__ */ new Set();
|
|
2006
|
+
stateStore;
|
|
2007
|
+
mediaDir;
|
|
2008
|
+
constructor(initialConfig, stateStore, mediaDir) {
|
|
2009
|
+
this.stateStore = stateStore;
|
|
2010
|
+
this.mediaDir = mediaDir;
|
|
2011
|
+
this.adapter = createWeixinAdapter(initialConfig, stateStore, mediaDir);
|
|
2012
|
+
}
|
|
2013
|
+
onMessage(handler) {
|
|
2014
|
+
this.handler = handler;
|
|
2015
|
+
this.adapter?.onMessage(handler);
|
|
2016
|
+
}
|
|
2017
|
+
onHealthChange(listener) {
|
|
2018
|
+
this.healthListeners.add(listener);
|
|
2019
|
+
this.adapter?.onHealthChange?.(listener);
|
|
2020
|
+
}
|
|
2021
|
+
attachHealthListeners() {
|
|
2022
|
+
if (!this.adapter?.onHealthChange) return;
|
|
2023
|
+
for (const listener of this.healthListeners) this.adapter.onHealthChange(listener);
|
|
2024
|
+
}
|
|
2025
|
+
async start(options) {
|
|
2026
|
+
await this.enqueue(async () => {
|
|
2027
|
+
this.running = true;
|
|
2028
|
+
if (!this.adapter) return;
|
|
2029
|
+
await this.adapter.start(options ?? { background: true });
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
async stop() {
|
|
2033
|
+
await this.enqueue(async () => {
|
|
2034
|
+
this.running = false;
|
|
2035
|
+
if (!this.adapter) return;
|
|
2036
|
+
await this.adapter.stop();
|
|
2037
|
+
});
|
|
2038
|
+
}
|
|
2039
|
+
async configure(config) {
|
|
2040
|
+
await this.enqueue(async () => {
|
|
2041
|
+
if (this.adapter) {
|
|
2042
|
+
await this.adapter.stop();
|
|
2043
|
+
}
|
|
2044
|
+
this.adapter = createWeixinAdapter(config, this.stateStore, this.mediaDir);
|
|
2045
|
+
if (this.adapter && this.handler) {
|
|
2046
|
+
this.adapter.onMessage(this.handler);
|
|
2047
|
+
}
|
|
2048
|
+
this.attachHealthListeners();
|
|
2049
|
+
if (this.running && this.adapter) {
|
|
2050
|
+
await this.adapter.start({ background: true });
|
|
2051
|
+
}
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
async sendMessage(message) {
|
|
2055
|
+
if (!this.adapter) throw new Error("weixin_channel_not_configured");
|
|
2056
|
+
await this.adapter.sendMessage(message);
|
|
2057
|
+
}
|
|
2058
|
+
async setTyping(chatId, active) {
|
|
2059
|
+
await this.adapter?.setTyping?.(chatId, active);
|
|
2060
|
+
}
|
|
2061
|
+
getHealth() {
|
|
2062
|
+
if (!this.adapter) return { connected: false, status: this.running ? "not_configured" : "disabled" };
|
|
2063
|
+
return this.adapter.getHealth?.() ?? { connected: this.running, status: this.running ? "configured" : "stopped" };
|
|
2064
|
+
}
|
|
2065
|
+
enqueue(task) {
|
|
2066
|
+
const result = this.operation.then(task, task);
|
|
2067
|
+
this.operation = result.then(() => void 0, () => void 0);
|
|
2068
|
+
return result;
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2071
|
+
function createWeixinAdapter(config, stateStore, mediaDir) {
|
|
2072
|
+
if (config?.enabled !== true) return null;
|
|
2073
|
+
if (!config.baseUrl || !config.token) return null;
|
|
2074
|
+
const wechatUin = buildTransientWeixinUin();
|
|
2075
|
+
return new WeixinDirectAdapter({
|
|
2076
|
+
api: new WeixinDirectApiClient({
|
|
2077
|
+
baseUrl: config.baseUrl,
|
|
2078
|
+
botToken: config.token,
|
|
2079
|
+
wechatUin
|
|
2080
|
+
}),
|
|
2081
|
+
stateStore,
|
|
2082
|
+
...mediaDir ? { mediaDownloader: new WeixinMediaDownloader(), mediaDir } : {}
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
function buildTransientWeixinUin() {
|
|
2086
|
+
const value = new Uint32Array(1);
|
|
2087
|
+
crypto.getRandomValues(value);
|
|
2088
|
+
return Buffer.from(String(value[0])).toString("base64");
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// src/channels/weixin-direct/outboundGate.ts
|
|
2092
|
+
var CONTINUATION_HINT = "\n\n\uFF08\u6D88\u606F\u8F83\u591A\u672A\u53D1\u5B8C\uFF0C\u8BF7\u56DE\u590D\u4EFB\u610F\u6D88\u606F\u7EE7\u7EED\u63A5\u6536\uFF09";
|
|
2093
|
+
var WeixinOutboundGate = class _WeixinOutboundGate {
|
|
2094
|
+
constructor(options) {
|
|
2095
|
+
this.options = options;
|
|
2096
|
+
}
|
|
2097
|
+
options;
|
|
2098
|
+
hintedReplies = /* @__PURE__ */ new Map();
|
|
2099
|
+
/** The message held back on the final quota slot, pending proof of a follow-up. */
|
|
2100
|
+
pendingLast = /* @__PURE__ */ new Map();
|
|
2101
|
+
static HINT_REPLY_TTL_MS = 2 * 60 * 60 * 1e3;
|
|
2102
|
+
hasPending(chatId) {
|
|
2103
|
+
return this.options.store.hasPendingOutbound(chatId);
|
|
2104
|
+
}
|
|
2105
|
+
shouldInterceptReply(chatId) {
|
|
2106
|
+
const hintedAt = this.hintedReplies.get(chatId);
|
|
2107
|
+
if (!hintedAt) return false;
|
|
2108
|
+
if (Date.now() - hintedAt > _WeixinOutboundGate.HINT_REPLY_TTL_MS) {
|
|
2109
|
+
this.hintedReplies.delete(chatId);
|
|
2110
|
+
return false;
|
|
2111
|
+
}
|
|
2112
|
+
this.hintedReplies.delete(chatId);
|
|
2113
|
+
return true;
|
|
2114
|
+
}
|
|
2115
|
+
async deliver(chatId, message) {
|
|
2116
|
+
const { store } = this.options;
|
|
2117
|
+
const buffered = this.pendingLast.get(chatId);
|
|
2118
|
+
if (buffered) {
|
|
2119
|
+
this.pendingLast.delete(chatId);
|
|
2120
|
+
await this.options.send(chatId, { kind: buffered.kind, text: buffered.text + CONTINUATION_HINT });
|
|
2121
|
+
this.hintedReplies.set(chatId, Date.now());
|
|
2122
|
+
store.enqueueOutbound(chatId, message);
|
|
2123
|
+
return;
|
|
2124
|
+
}
|
|
2125
|
+
if (store.hasPendingOutbound(chatId)) {
|
|
2126
|
+
store.enqueueOutbound(chatId, message);
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
const quota = store.getQuota(chatId);
|
|
2130
|
+
if (quota.expired || quota.remaining <= 0) {
|
|
2131
|
+
store.enqueueOutbound(chatId, message);
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
if (quota.remaining === 1) {
|
|
2135
|
+
this.pendingLast.set(chatId, message);
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
await this.options.send(chatId, message);
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Flush any message buffered on the final slot, sent WITHOUT a hint because the
|
|
2142
|
+
* turn ended with no follow-up. Called when a logical batch (a generation or a
|
|
2143
|
+
* command) finishes producing output.
|
|
2144
|
+
*/
|
|
2145
|
+
async finalize(chatId) {
|
|
2146
|
+
const buffered = this.pendingLast.get(chatId);
|
|
2147
|
+
if (!buffered) return;
|
|
2148
|
+
this.pendingLast.delete(chatId);
|
|
2149
|
+
await this.options.send(chatId, buffered);
|
|
2150
|
+
}
|
|
2151
|
+
async drain(chatId) {
|
|
2152
|
+
const { store } = this.options;
|
|
2153
|
+
while (store.hasPendingOutbound(chatId)) {
|
|
2154
|
+
const quota = store.getQuota(chatId);
|
|
2155
|
+
if (quota.expired || quota.remaining <= 0) break;
|
|
2156
|
+
const pending = store.peekOutbound(chatId);
|
|
2157
|
+
const next = pending[0];
|
|
2158
|
+
const isLastSlot = quota.remaining === 1;
|
|
2159
|
+
const hasMore = pending.length > 1;
|
|
2160
|
+
if (isLastSlot && hasMore) {
|
|
2161
|
+
await this.options.send(chatId, { kind: next.kind, text: next.text + CONTINUATION_HINT });
|
|
2162
|
+
this.hintedReplies.set(chatId, Date.now());
|
|
2163
|
+
store.shiftOutbound(chatId);
|
|
2164
|
+
break;
|
|
2165
|
+
}
|
|
2166
|
+
await this.options.send(chatId, next);
|
|
2167
|
+
store.shiftOutbound(chatId);
|
|
2168
|
+
}
|
|
2169
|
+
if (!store.hasPendingOutbound(chatId)) this.hintedReplies.delete(chatId);
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
|
|
2173
|
+
// src/providers/claude-code/claudeStreamingRunner.ts
|
|
2174
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2175
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2176
|
+
|
|
2177
|
+
// src/shared/expandTilde.ts
|
|
2178
|
+
import { homedir as homedir6 } from "node:os";
|
|
2179
|
+
function expandTilde(target) {
|
|
2180
|
+
if (!target) return target;
|
|
2181
|
+
if (target === "~") return homedir6();
|
|
2182
|
+
if (target.startsWith("~/")) return homedir6() + target.slice(1);
|
|
2183
|
+
return target;
|
|
2184
|
+
}
|
|
2185
|
+
|
|
2186
|
+
// src/shared/platform.ts
|
|
2187
|
+
import { access, constants } from "node:fs/promises";
|
|
2188
|
+
import { delimiter, join as join7 } from "node:path";
|
|
2189
|
+
import { tmpdir } from "node:os";
|
|
2190
|
+
import { spawn } from "node:child_process";
|
|
2191
|
+
var isWindows = process.platform === "win32";
|
|
2192
|
+
async function isExecutableFile(candidate) {
|
|
2193
|
+
try {
|
|
2194
|
+
await access(candidate, isWindows ? constants.F_OK : constants.X_OK);
|
|
2195
|
+
return true;
|
|
2196
|
+
} catch {
|
|
2197
|
+
return false;
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2200
|
+
function executableExtensions() {
|
|
2201
|
+
if (!isWindows) return [""];
|
|
2202
|
+
const pathext = process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD";
|
|
2203
|
+
return ["", ...pathext.split(";").filter(Boolean)];
|
|
2204
|
+
}
|
|
2205
|
+
async function findExecutable(command) {
|
|
2206
|
+
const extensions = executableExtensions();
|
|
2207
|
+
if (command.includes("/") || command.includes("\\")) {
|
|
2208
|
+
for (const ext of extensions) {
|
|
2209
|
+
if (await isExecutableFile(command + ext)) return command + ext;
|
|
2210
|
+
}
|
|
2211
|
+
return void 0;
|
|
2212
|
+
}
|
|
2213
|
+
const dirs = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
2214
|
+
for (const dir of dirs) {
|
|
2215
|
+
for (const ext of extensions) {
|
|
2216
|
+
const candidate = join7(dir, command + ext);
|
|
2217
|
+
if (await isExecutableFile(candidate)) return candidate;
|
|
2218
|
+
}
|
|
2219
|
+
}
|
|
2220
|
+
return void 0;
|
|
2221
|
+
}
|
|
2222
|
+
function terminateChild(child, signal = "SIGTERM") {
|
|
2223
|
+
if (child.pid === void 0 || child.exitCode !== null || child.signalCode !== null) {
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
if (isWindows) {
|
|
2227
|
+
try {
|
|
2228
|
+
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
2229
|
+
return;
|
|
2230
|
+
} catch {
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
try {
|
|
2234
|
+
child.kill(signal);
|
|
2235
|
+
} catch {
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
function defaultWorkspaceDir() {
|
|
2239
|
+
return join7(tmpdir(), "project");
|
|
2240
|
+
}
|
|
2241
|
+
function statePath(filename) {
|
|
2242
|
+
return join7(tmpdir(), filename);
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
// src/providers/claude-code/claudeStreamingRunner.ts
|
|
2246
|
+
var ClaudeStreamingRunner = class {
|
|
2247
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2248
|
+
command;
|
|
2249
|
+
spawner;
|
|
2250
|
+
constructor(input = {}) {
|
|
2251
|
+
this.command = input.command ?? "claude";
|
|
2252
|
+
this.spawner = input.spawner ?? defaultClaudeStreamSpawner;
|
|
2253
|
+
}
|
|
2254
|
+
async startSession(input) {
|
|
2255
|
+
const resumeId = input.options?.providerSessionId;
|
|
2256
|
+
const session = {
|
|
2257
|
+
bridgeSessionId: input.bridgeSessionId,
|
|
2258
|
+
providerId: "claude-code",
|
|
2259
|
+
providerSessionId: resumeId,
|
|
2260
|
+
claudeSessionId: resumeId,
|
|
2261
|
+
sessionName: input.options?.sessionName,
|
|
2262
|
+
cwd: input.cwd,
|
|
2263
|
+
status: "idle",
|
|
2264
|
+
resumeId,
|
|
2265
|
+
pendingFollowUps: 0
|
|
2266
|
+
};
|
|
2267
|
+
this.sessions.set(input.bridgeSessionId, session);
|
|
2268
|
+
return session;
|
|
2269
|
+
}
|
|
2270
|
+
async *sendMessage(input) {
|
|
2271
|
+
const session = this.sessions.get(input.bridgeSessionId);
|
|
2272
|
+
if (!session) throw new Error(`claude_session_not_found:${input.bridgeSessionId}`);
|
|
2273
|
+
const handle = this.ensureHandle(session);
|
|
2274
|
+
session.pendingFollowUps = 0;
|
|
2275
|
+
handle.write(userEnvelope(input.text));
|
|
2276
|
+
while (true) {
|
|
2277
|
+
const chunk = await handle.read();
|
|
2278
|
+
if (chunk.type === "exit") {
|
|
2279
|
+
session.handle = void 0;
|
|
2280
|
+
yield { type: "error", error: chunk.stderr || `claude stream exited with ${chunk.code}` };
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
const value = parseJsonLine(chunk.line);
|
|
2284
|
+
if (!value) continue;
|
|
2285
|
+
const record = value;
|
|
2286
|
+
const type = record.type;
|
|
2287
|
+
if (type === "user" && record.isReplay === true) continue;
|
|
2288
|
+
if (type === "system" && record.subtype === "init") {
|
|
2289
|
+
const sessionId = typeof record.session_id === "string" ? record.session_id : void 0;
|
|
2290
|
+
if (sessionId) {
|
|
2291
|
+
session.providerSessionId = sessionId;
|
|
2292
|
+
session.claudeSessionId = sessionId;
|
|
2293
|
+
session.resumeId = sessionId;
|
|
2294
|
+
yield {
|
|
2295
|
+
type: "session_state",
|
|
2296
|
+
state: {
|
|
2297
|
+
bridgeSessionId: input.bridgeSessionId,
|
|
2298
|
+
providerId: "claude-code",
|
|
2299
|
+
providerSessionId: sessionId,
|
|
2300
|
+
cwd: session.cwd,
|
|
2301
|
+
status: "running"
|
|
2302
|
+
}
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
if (type === "assistant") {
|
|
2308
|
+
let emitted = false;
|
|
2309
|
+
for (const text of extractAssistantText(record.message)) {
|
|
2310
|
+
yield { type: "text_delta", text };
|
|
2311
|
+
emitted = true;
|
|
2312
|
+
}
|
|
2313
|
+
if (emitted) yield { type: "message_done" };
|
|
2314
|
+
continue;
|
|
2315
|
+
}
|
|
2316
|
+
if (type === "result") {
|
|
2317
|
+
const sessionId = typeof record.session_id === "string" ? record.session_id : void 0;
|
|
2318
|
+
if (sessionId) {
|
|
2319
|
+
session.providerSessionId = sessionId;
|
|
2320
|
+
session.claudeSessionId = sessionId;
|
|
2321
|
+
session.resumeId = sessionId;
|
|
2322
|
+
}
|
|
2323
|
+
yield { type: "message_done" };
|
|
2324
|
+
if (session.pendingFollowUps > 0) {
|
|
2325
|
+
session.pendingFollowUps -= 1;
|
|
2326
|
+
continue;
|
|
2327
|
+
}
|
|
2328
|
+
return;
|
|
2329
|
+
}
|
|
2330
|
+
if (type === "error") {
|
|
2331
|
+
yield { type: "error", error: extractError(record) };
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
// Native steer for Claude = write a follow-up user envelope into the live
|
|
2336
|
+
// process. Claude queues it and processes it as the next turn; the active
|
|
2337
|
+
// sendMessage keeps consuming so its output reaches the channel.
|
|
2338
|
+
async steerSession(bridgeSessionId, text) {
|
|
2339
|
+
const session = this.sessions.get(bridgeSessionId);
|
|
2340
|
+
if (!session?.handle) return;
|
|
2341
|
+
session.handle.write(userEnvelope(text));
|
|
2342
|
+
session.pendingFollowUps += 1;
|
|
2343
|
+
}
|
|
2344
|
+
async interruptSession(bridgeSessionId) {
|
|
2345
|
+
const session = this.sessions.get(bridgeSessionId);
|
|
2346
|
+
session?.handle?.write(`${JSON.stringify({ type: "control_request", request_id: `int_${randomUUID2()}`, request: { subtype: "interrupt" } })}
|
|
2347
|
+
`);
|
|
2348
|
+
}
|
|
2349
|
+
async stopSession(bridgeSessionId) {
|
|
2350
|
+
const session = this.sessions.get(bridgeSessionId);
|
|
2351
|
+
session?.handle?.close();
|
|
2352
|
+
this.sessions.delete(bridgeSessionId);
|
|
2353
|
+
}
|
|
2354
|
+
ensureHandle(session) {
|
|
2355
|
+
if (session.handle) return session.handle;
|
|
2356
|
+
const args = buildStreamingArgs(session);
|
|
2357
|
+
session.handle = this.spawner({ command: this.command, args, cwd: session.cwd });
|
|
2358
|
+
return session.handle;
|
|
2359
|
+
}
|
|
2360
|
+
};
|
|
2361
|
+
function buildStreamingArgs(session) {
|
|
2362
|
+
const args = [
|
|
2363
|
+
"-p",
|
|
2364
|
+
"--input-format",
|
|
2365
|
+
"stream-json",
|
|
2366
|
+
"--output-format",
|
|
2367
|
+
"stream-json",
|
|
2368
|
+
"--include-partial-messages",
|
|
2369
|
+
"--verbose",
|
|
2370
|
+
"--dangerously-skip-permissions",
|
|
2371
|
+
"--replay-user-messages"
|
|
2372
|
+
];
|
|
2373
|
+
if (session.resumeId) {
|
|
2374
|
+
args.push("--resume", session.resumeId);
|
|
2375
|
+
} else {
|
|
2376
|
+
const minted = randomUUID2();
|
|
2377
|
+
session.resumeId = minted;
|
|
2378
|
+
session.providerSessionId = minted;
|
|
2379
|
+
session.claudeSessionId = minted;
|
|
2380
|
+
args.push("--session-id", minted);
|
|
2381
|
+
}
|
|
2382
|
+
if (session.sessionName) args.push("-n", session.sessionName);
|
|
2383
|
+
return args;
|
|
2384
|
+
}
|
|
2385
|
+
function userEnvelope(text) {
|
|
2386
|
+
return `${JSON.stringify({ type: "user", message: { role: "user", content: text }, parent_tool_use_id: null })}
|
|
2387
|
+
`;
|
|
2388
|
+
}
|
|
2389
|
+
function extractAssistantText(message) {
|
|
2390
|
+
if (!message || typeof message !== "object") return [];
|
|
2391
|
+
const content = message.content;
|
|
2392
|
+
if (!Array.isArray(content)) return [];
|
|
2393
|
+
return content.flatMap((item) => {
|
|
2394
|
+
if (!item || typeof item !== "object") return [];
|
|
2395
|
+
const record = item;
|
|
2396
|
+
return record.type === "text" && typeof record.text === "string" ? [record.text] : [];
|
|
2397
|
+
});
|
|
2398
|
+
}
|
|
2399
|
+
function extractError(record) {
|
|
2400
|
+
if (typeof record.message === "string") return record.message;
|
|
2401
|
+
if (record.error && typeof record.error === "object") {
|
|
2402
|
+
const error = record.error;
|
|
2403
|
+
if (typeof error.message === "string") return error.message;
|
|
2404
|
+
}
|
|
2405
|
+
return JSON.stringify(record);
|
|
2406
|
+
}
|
|
2407
|
+
function parseJsonLine(line) {
|
|
2408
|
+
try {
|
|
2409
|
+
return JSON.parse(line);
|
|
2410
|
+
} catch {
|
|
2411
|
+
return null;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
function defaultClaudeStreamSpawner(call) {
|
|
2415
|
+
const child = spawn2(call.command, call.args, { cwd: expandTilde(call.cwd) ?? call.cwd, stdio: ["pipe", "pipe", "pipe"] });
|
|
2416
|
+
let stderr = "";
|
|
2417
|
+
let buffer = "";
|
|
2418
|
+
const queue = [];
|
|
2419
|
+
let resolveNext = null;
|
|
2420
|
+
const push = (chunk) => {
|
|
2421
|
+
const resolve = resolveNext;
|
|
2422
|
+
if (resolve) {
|
|
2423
|
+
resolveNext = null;
|
|
2424
|
+
resolve(chunk);
|
|
2425
|
+
} else {
|
|
2426
|
+
queue.push(chunk);
|
|
2427
|
+
}
|
|
2428
|
+
};
|
|
2429
|
+
child.stdout.on("data", (data) => {
|
|
2430
|
+
buffer += String(data);
|
|
2431
|
+
const parts = buffer.split(/\r?\n/);
|
|
2432
|
+
buffer = parts.pop() ?? "";
|
|
2433
|
+
for (const line of parts) {
|
|
2434
|
+
if (line.trim()) push({ type: "line", line });
|
|
2435
|
+
}
|
|
2436
|
+
});
|
|
2437
|
+
child.stderr.on("data", (data) => {
|
|
2438
|
+
stderr += String(data);
|
|
2439
|
+
});
|
|
2440
|
+
child.on("error", (error) => {
|
|
2441
|
+
push({ type: "exit", code: error.code ?? "ERROR", stderr: stderr || error.message });
|
|
2442
|
+
});
|
|
2443
|
+
child.on("close", (code) => {
|
|
2444
|
+
if (buffer.trim()) push({ type: "line", line: buffer });
|
|
2445
|
+
push({ type: "exit", code: code ?? "SIGNAL", stderr });
|
|
2446
|
+
});
|
|
2447
|
+
return {
|
|
2448
|
+
write(line) {
|
|
2449
|
+
child.stdin.write(line);
|
|
2450
|
+
},
|
|
2451
|
+
read() {
|
|
2452
|
+
const next = queue.shift();
|
|
2453
|
+
if (next) return Promise.resolve(next);
|
|
2454
|
+
return new Promise((resolve) => {
|
|
2455
|
+
resolveNext = resolve;
|
|
2456
|
+
});
|
|
2457
|
+
},
|
|
2458
|
+
close() {
|
|
2459
|
+
try {
|
|
2460
|
+
child.stdin.end();
|
|
2461
|
+
} catch {
|
|
2462
|
+
}
|
|
2463
|
+
terminateChild(child);
|
|
2464
|
+
}
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
// src/providers/claude-code/claudeProvider.ts
|
|
2469
|
+
var ClaudeCodeProvider = class {
|
|
2470
|
+
constructor(options) {
|
|
2471
|
+
this.options = options;
|
|
2472
|
+
}
|
|
2473
|
+
options;
|
|
2474
|
+
id = "claude-code";
|
|
2475
|
+
async startSession(input) {
|
|
2476
|
+
return await this.options.runner.startSession({
|
|
2477
|
+
bridgeSessionId: input.bridgeSessionId,
|
|
2478
|
+
cwd: input.cwd,
|
|
2479
|
+
initialPrompt: input.initialPrompt,
|
|
2480
|
+
options: typeof input.options === "object" && input.options ? {
|
|
2481
|
+
providerSessionId: typeof input.options.providerSessionId === "string" ? input.options.providerSessionId : void 0,
|
|
2482
|
+
sessionName: typeof input.options.sessionName === "string" ? input.options.sessionName : void 0
|
|
2483
|
+
} : void 0
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
async *sendMessage(input) {
|
|
2487
|
+
yield* this.options.runner.sendMessage({
|
|
2488
|
+
bridgeSessionId: input.bridgeSessionId,
|
|
2489
|
+
text: input.text
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
async stopSession(bridgeSessionId) {
|
|
2493
|
+
await this.options.runner.stopSession(bridgeSessionId);
|
|
2494
|
+
}
|
|
2495
|
+
async interruptSession(bridgeSessionId) {
|
|
2496
|
+
await this.options.runner.interruptSession?.(bridgeSessionId);
|
|
2497
|
+
}
|
|
2498
|
+
async steerSession(bridgeSessionId, text) {
|
|
2499
|
+
await this.options.runner.steerSession?.(bridgeSessionId, text);
|
|
2500
|
+
}
|
|
2501
|
+
async listRecoverableSessions() {
|
|
2502
|
+
return await listRecoverableClaudeSessions();
|
|
2503
|
+
}
|
|
2504
|
+
async attachSession(input) {
|
|
2505
|
+
return await this.options.runner.startSession({
|
|
2506
|
+
bridgeSessionId: input.bridgeSessionId,
|
|
2507
|
+
cwd: input.cwd,
|
|
2508
|
+
options: { providerSessionId: input.candidateId }
|
|
2509
|
+
});
|
|
2510
|
+
}
|
|
2511
|
+
};
|
|
2512
|
+
|
|
2513
|
+
// src/providers/codex/codexAppServerClient.ts
|
|
2514
|
+
import { spawn as spawn3 } from "node:child_process";
|
|
2515
|
+
var CodexAppServerClient = class {
|
|
2516
|
+
child;
|
|
2517
|
+
pending = /* @__PURE__ */ new Map();
|
|
2518
|
+
notificationHandlers = /* @__PURE__ */ new Map();
|
|
2519
|
+
requestHandlers = /* @__PURE__ */ new Map();
|
|
2520
|
+
nextId = 1;
|
|
2521
|
+
disposed = false;
|
|
2522
|
+
buffer = "";
|
|
2523
|
+
constructor(input) {
|
|
2524
|
+
const command = input.command ?? "codex";
|
|
2525
|
+
this.child = spawn3(command, ["app-server", "--listen", "stdio://"], {
|
|
2526
|
+
cwd: input.cwd,
|
|
2527
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
2528
|
+
});
|
|
2529
|
+
this.child.stdout.on("data", (chunk) => this.onStdout(String(chunk)));
|
|
2530
|
+
this.child.stderr.on("data", () => {
|
|
2531
|
+
});
|
|
2532
|
+
this.child.on("error", (error) => this.failAll(error instanceof Error ? error : new Error(String(error))));
|
|
2533
|
+
this.child.on("close", (code) => {
|
|
2534
|
+
this.failAll(new Error(`codex_app_server_closed:${code ?? "signal"}`));
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
async initialize() {
|
|
2538
|
+
await this.request("initialize", {
|
|
2539
|
+
clientInfo: {
|
|
2540
|
+
name: "claude-codex-wechat",
|
|
2541
|
+
version: "0.1.0"
|
|
2542
|
+
},
|
|
2543
|
+
capabilities: {
|
|
2544
|
+
experimentalApi: true
|
|
2545
|
+
}
|
|
2546
|
+
});
|
|
2547
|
+
await this.notify("initialized");
|
|
2548
|
+
}
|
|
2549
|
+
async request(method, params) {
|
|
2550
|
+
if (this.disposed) throw new Error("codex_app_server_disposed");
|
|
2551
|
+
const id = this.nextId++;
|
|
2552
|
+
const key = String(id);
|
|
2553
|
+
const payload = { id, method, ...params !== void 0 ? { params } : {} };
|
|
2554
|
+
const response = new Promise((resolve, reject) => {
|
|
2555
|
+
this.pending.set(key, { resolve, reject });
|
|
2556
|
+
});
|
|
2557
|
+
this.child.stdin.write(`${JSON.stringify(payload)}
|
|
2558
|
+
`);
|
|
2559
|
+
return await response;
|
|
2560
|
+
}
|
|
2561
|
+
async notify(method, params) {
|
|
2562
|
+
if (this.disposed) throw new Error("codex_app_server_disposed");
|
|
2563
|
+
const payload = { method, ...params !== void 0 ? { params } : {} };
|
|
2564
|
+
this.child.stdin.write(`${JSON.stringify(payload)}
|
|
2565
|
+
`);
|
|
2566
|
+
}
|
|
2567
|
+
onNotification(method, handler) {
|
|
2568
|
+
const handlers = this.notificationHandlers.get(method) ?? /* @__PURE__ */ new Set();
|
|
2569
|
+
handlers.add(handler);
|
|
2570
|
+
this.notificationHandlers.set(method, handlers);
|
|
2571
|
+
return () => {
|
|
2572
|
+
handlers.delete(handler);
|
|
2573
|
+
if (handlers.size === 0) this.notificationHandlers.delete(method);
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
onRequest(method, handler) {
|
|
2577
|
+
this.requestHandlers.set(method, handler);
|
|
2578
|
+
return () => {
|
|
2579
|
+
if (this.requestHandlers.get(method) === handler) {
|
|
2580
|
+
this.requestHandlers.delete(method);
|
|
2581
|
+
}
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
async dispose() {
|
|
2585
|
+
if (this.disposed) return;
|
|
2586
|
+
this.disposed = true;
|
|
2587
|
+
terminateChild(this.child);
|
|
2588
|
+
this.failAll(new Error("codex_app_server_disposed"));
|
|
2589
|
+
}
|
|
2590
|
+
onStdout(chunk) {
|
|
2591
|
+
this.buffer += chunk;
|
|
2592
|
+
for (; ; ) {
|
|
2593
|
+
const newline = this.buffer.indexOf("\n");
|
|
2594
|
+
if (newline === -1) break;
|
|
2595
|
+
const line = this.buffer.slice(0, newline).trim();
|
|
2596
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
2597
|
+
if (!line) continue;
|
|
2598
|
+
let message;
|
|
2599
|
+
try {
|
|
2600
|
+
message = JSON.parse(line);
|
|
2601
|
+
} catch {
|
|
2602
|
+
continue;
|
|
2603
|
+
}
|
|
2604
|
+
void this.handleMessage(message);
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
async handleMessage(message) {
|
|
2608
|
+
if (message.method && message.id !== void 0 && message.id !== null) {
|
|
2609
|
+
const handler = this.requestHandlers.get(message.method);
|
|
2610
|
+
if (!handler) {
|
|
2611
|
+
this.child.stdin.write(`${JSON.stringify({
|
|
2612
|
+
id: message.id,
|
|
2613
|
+
error: { code: -32601, message: `unhandled_method:${message.method}` }
|
|
2614
|
+
})}
|
|
2615
|
+
`);
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
try {
|
|
2619
|
+
const result = await handler(message.id, message.params);
|
|
2620
|
+
this.child.stdin.write(`${JSON.stringify({ id: message.id, result })}
|
|
2621
|
+
`);
|
|
2622
|
+
} catch (error) {
|
|
2623
|
+
this.child.stdin.write(`${JSON.stringify({
|
|
2624
|
+
id: message.id,
|
|
2625
|
+
error: { code: -32e3, message: error instanceof Error ? error.message : String(error) }
|
|
2626
|
+
})}
|
|
2627
|
+
`);
|
|
2628
|
+
}
|
|
2629
|
+
return;
|
|
2630
|
+
}
|
|
2631
|
+
if (message.method) {
|
|
2632
|
+
const handlers = this.notificationHandlers.get(message.method);
|
|
2633
|
+
if (!handlers) return;
|
|
2634
|
+
for (const handler of handlers) handler(message.params);
|
|
2635
|
+
return;
|
|
2636
|
+
}
|
|
2637
|
+
if (message.id === void 0 || message.id === null) return;
|
|
2638
|
+
const key = String(message.id);
|
|
2639
|
+
const pending = this.pending.get(key);
|
|
2640
|
+
if (!pending) return;
|
|
2641
|
+
this.pending.delete(key);
|
|
2642
|
+
if (message.error) {
|
|
2643
|
+
pending.reject(new Error(message.error.message || `codex_app_server_error:${message.error.code ?? "unknown"}`));
|
|
2644
|
+
return;
|
|
2645
|
+
}
|
|
2646
|
+
pending.resolve(message.result);
|
|
2647
|
+
}
|
|
2648
|
+
failAll(error) {
|
|
2649
|
+
if (this.pending.size === 0) return;
|
|
2650
|
+
for (const { reject } of this.pending.values()) reject(error);
|
|
2651
|
+
this.pending.clear();
|
|
2652
|
+
}
|
|
2653
|
+
};
|
|
2654
|
+
|
|
2655
|
+
// src/providers/codex/codexInteractiveRunner.ts
|
|
2656
|
+
function readThreadId(value) {
|
|
2657
|
+
if (!value || typeof value !== "object") return void 0;
|
|
2658
|
+
const record = value;
|
|
2659
|
+
if (typeof record.threadId === "string" && record.threadId) return record.threadId;
|
|
2660
|
+
if (typeof record.id === "string" && record.id) return record.id;
|
|
2661
|
+
if (record.thread && typeof record.thread === "object") {
|
|
2662
|
+
const thread = record.thread;
|
|
2663
|
+
if (typeof thread.threadId === "string" && thread.threadId) return thread.threadId;
|
|
2664
|
+
if (typeof thread.id === "string" && thread.id) return thread.id;
|
|
2665
|
+
}
|
|
2666
|
+
return void 0;
|
|
2667
|
+
}
|
|
2668
|
+
function readTurnId(value) {
|
|
2669
|
+
if (!value || typeof value !== "object") return void 0;
|
|
2670
|
+
const record = value;
|
|
2671
|
+
if (record.turn && typeof record.turn === "object") {
|
|
2672
|
+
const turn = record.turn;
|
|
2673
|
+
if (typeof turn.id === "string" && turn.id) return turn.id;
|
|
2674
|
+
if (typeof turn.turnId === "string" && turn.turnId) return turn.turnId;
|
|
2675
|
+
}
|
|
2676
|
+
if (typeof record.turnId === "string" && record.turnId) return record.turnId;
|
|
2677
|
+
return void 0;
|
|
2678
|
+
}
|
|
2679
|
+
var CodexInteractiveRunner = class {
|
|
2680
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2681
|
+
command;
|
|
2682
|
+
syncThreadForResume;
|
|
2683
|
+
constructor(input = {}) {
|
|
2684
|
+
this.command = input.command;
|
|
2685
|
+
this.syncThreadForResume = input.syncThreadForResume ?? syncCodexThreadForResume;
|
|
2686
|
+
}
|
|
2687
|
+
async startSession(input) {
|
|
2688
|
+
const session = {
|
|
2689
|
+
bridgeSessionId: input.bridgeSessionId,
|
|
2690
|
+
providerId: "codex",
|
|
2691
|
+
providerSessionId: input.options?.providerSessionId,
|
|
2692
|
+
threadId: input.options?.providerSessionId,
|
|
2693
|
+
sessionName: input.options?.sessionName,
|
|
2694
|
+
cwd: input.cwd,
|
|
2695
|
+
status: "idle",
|
|
2696
|
+
pendingText: []
|
|
2697
|
+
};
|
|
2698
|
+
this.sessions.set(input.bridgeSessionId, session);
|
|
2699
|
+
return session;
|
|
2700
|
+
}
|
|
2701
|
+
async *sendMessage(input) {
|
|
2702
|
+
const session = this.sessions.get(input.bridgeSessionId);
|
|
2703
|
+
if (!session) throw new Error(`codex_session_not_found:${input.bridgeSessionId}`);
|
|
2704
|
+
const client = await this.ensureClient(session);
|
|
2705
|
+
session.pendingText = [];
|
|
2706
|
+
session.activeTurnId = void 0;
|
|
2707
|
+
session.turnCompletedPromise = new Promise((resolve) => {
|
|
2708
|
+
session.turnCompletedResolver = resolve;
|
|
2709
|
+
});
|
|
2710
|
+
if (session.threadId) {
|
|
2711
|
+
await client.request("thread/resume", {
|
|
2712
|
+
threadId: session.threadId,
|
|
2713
|
+
cwd: session.cwd,
|
|
2714
|
+
persistExtendedHistory: true
|
|
2715
|
+
}).catch(() => void 0);
|
|
2716
|
+
} else {
|
|
2717
|
+
const started = await client.request("thread/start", {
|
|
2718
|
+
cwd: session.cwd,
|
|
2719
|
+
threadSource: "user",
|
|
2720
|
+
persistExtendedHistory: true,
|
|
2721
|
+
experimentalRawEvents: true,
|
|
2722
|
+
sandboxPolicy: { type: "disabled" },
|
|
2723
|
+
approvalMode: "never"
|
|
2724
|
+
});
|
|
2725
|
+
session.threadId = readThreadId(started);
|
|
2726
|
+
session.providerSessionId = session.threadId;
|
|
2727
|
+
if (!session.threadId) throw new Error("codex_app_server_missing_thread_id");
|
|
2728
|
+
yield {
|
|
2729
|
+
type: "session_state",
|
|
2730
|
+
state: {
|
|
2731
|
+
bridgeSessionId: session.bridgeSessionId,
|
|
2732
|
+
providerId: "codex",
|
|
2733
|
+
providerSessionId: session.threadId,
|
|
2734
|
+
cwd: session.cwd,
|
|
2735
|
+
status: "idle"
|
|
2736
|
+
}
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
const response = await client.request("turn/start", {
|
|
2740
|
+
threadId: session.threadId,
|
|
2741
|
+
cwd: session.cwd,
|
|
2742
|
+
input: [{ type: "text", text: input.text }]
|
|
2743
|
+
});
|
|
2744
|
+
const maybeTurnId = readTurnId(response);
|
|
2745
|
+
if (maybeTurnId) session.activeTurnId = maybeTurnId;
|
|
2746
|
+
await session.turnCompletedPromise;
|
|
2747
|
+
if (session.threadId) {
|
|
2748
|
+
await this.syncThreadForResume({
|
|
2749
|
+
sessionId: session.threadId,
|
|
2750
|
+
resumeTitle: session.sessionName ?? input.text,
|
|
2751
|
+
cwd: session.cwd
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
for (const text of session.pendingText) {
|
|
2755
|
+
yield { type: "text_delta", text };
|
|
2756
|
+
}
|
|
2757
|
+
yield {
|
|
2758
|
+
type: "session_state",
|
|
2759
|
+
state: {
|
|
2760
|
+
bridgeSessionId: session.bridgeSessionId,
|
|
2761
|
+
providerId: "codex",
|
|
2762
|
+
providerSessionId: session.threadId,
|
|
2763
|
+
cwd: session.cwd,
|
|
2764
|
+
status: "idle"
|
|
2765
|
+
}
|
|
2766
|
+
};
|
|
2767
|
+
yield { type: "message_done" };
|
|
2768
|
+
}
|
|
2769
|
+
async stopSession(bridgeSessionId) {
|
|
2770
|
+
const session = this.sessions.get(bridgeSessionId);
|
|
2771
|
+
this.sessions.delete(bridgeSessionId);
|
|
2772
|
+
await session?.client?.dispose();
|
|
2773
|
+
}
|
|
2774
|
+
async interruptTurn(bridgeSessionId) {
|
|
2775
|
+
const session = this.sessions.get(bridgeSessionId);
|
|
2776
|
+
if (!session?.client) return;
|
|
2777
|
+
if (session.threadId && session.activeTurnId) {
|
|
2778
|
+
await session.client.request("turn/interrupt", {
|
|
2779
|
+
threadId: session.threadId,
|
|
2780
|
+
turnId: session.activeTurnId
|
|
2781
|
+
}).catch(() => void 0);
|
|
2782
|
+
}
|
|
2783
|
+
session.turnCompletedResolver?.();
|
|
2784
|
+
session.turnCompletedResolver = void 0;
|
|
2785
|
+
}
|
|
2786
|
+
async steerTurn(bridgeSessionId, text) {
|
|
2787
|
+
const session = this.sessions.get(bridgeSessionId);
|
|
2788
|
+
if (!session?.client || !session.threadId || !session.activeTurnId) return;
|
|
2789
|
+
await session.client.request("turn/steer", {
|
|
2790
|
+
threadId: session.threadId,
|
|
2791
|
+
expectedTurnId: session.activeTurnId,
|
|
2792
|
+
input: [{ type: "text", text }]
|
|
2793
|
+
}).catch(() => void 0);
|
|
2794
|
+
}
|
|
2795
|
+
async ensureClient(session) {
|
|
2796
|
+
if (session.client) return session.client;
|
|
2797
|
+
const client = new CodexAppServerClient({
|
|
2798
|
+
command: this.command,
|
|
2799
|
+
cwd: session.cwd
|
|
2800
|
+
});
|
|
2801
|
+
await client.initialize();
|
|
2802
|
+
client.onNotification("item/agentMessage/delta", (params) => {
|
|
2803
|
+
const record = params;
|
|
2804
|
+
if (typeof record.delta === "string" && record.delta) session.pendingText.push(record.delta);
|
|
2805
|
+
});
|
|
2806
|
+
client.onNotification("turn/started", (params) => {
|
|
2807
|
+
const turnId = readTurnId(params);
|
|
2808
|
+
if (turnId) session.activeTurnId = turnId;
|
|
2809
|
+
});
|
|
2810
|
+
client.onNotification("turn/completed", () => {
|
|
2811
|
+
session.turnCompletedResolver?.();
|
|
2812
|
+
session.turnCompletedResolver = void 0;
|
|
2813
|
+
});
|
|
2814
|
+
client.onRequest("item/commandExecution/requestApproval", async (_id, params) => {
|
|
2815
|
+
return { decision: "approve" };
|
|
2816
|
+
});
|
|
2817
|
+
client.onRequest("item/fileChange/requestApproval", async (_id, params) => {
|
|
2818
|
+
return { decision: "approve" };
|
|
2819
|
+
});
|
|
2820
|
+
session.client = client;
|
|
2821
|
+
return client;
|
|
2822
|
+
}
|
|
2823
|
+
};
|
|
2824
|
+
|
|
2825
|
+
// src/providers/codex/codexProvider.ts
|
|
2826
|
+
var CodexProvider = class {
|
|
2827
|
+
constructor(options) {
|
|
2828
|
+
this.options = options;
|
|
2829
|
+
}
|
|
2830
|
+
options;
|
|
2831
|
+
id = "codex";
|
|
2832
|
+
async startSession(input) {
|
|
2833
|
+
return await this.options.runner.startSession({
|
|
2834
|
+
bridgeSessionId: input.bridgeSessionId,
|
|
2835
|
+
cwd: input.cwd,
|
|
2836
|
+
initialPrompt: input.initialPrompt,
|
|
2837
|
+
options: typeof input.options === "object" && input.options ? {
|
|
2838
|
+
providerSessionId: typeof input.options.providerSessionId === "string" ? input.options.providerSessionId : void 0,
|
|
2839
|
+
sessionName: typeof input.options.sessionName === "string" ? input.options.sessionName : void 0
|
|
2840
|
+
} : void 0
|
|
2841
|
+
});
|
|
2842
|
+
}
|
|
2843
|
+
async *sendMessage(input) {
|
|
2844
|
+
yield* this.options.runner.sendMessage({
|
|
2845
|
+
bridgeSessionId: input.bridgeSessionId,
|
|
2846
|
+
text: input.text
|
|
2847
|
+
});
|
|
2848
|
+
}
|
|
2849
|
+
async stopSession(bridgeSessionId) {
|
|
2850
|
+
await this.options.runner.stopSession(bridgeSessionId);
|
|
2851
|
+
}
|
|
2852
|
+
async listRecoverableSessions() {
|
|
2853
|
+
return await listRecoverableCodexSessions();
|
|
2854
|
+
}
|
|
2855
|
+
async attachSession(input) {
|
|
2856
|
+
return await this.options.runner.startSession({
|
|
2857
|
+
bridgeSessionId: input.bridgeSessionId,
|
|
2858
|
+
cwd: input.cwd,
|
|
2859
|
+
options: { providerSessionId: input.candidateId }
|
|
2860
|
+
});
|
|
2861
|
+
}
|
|
2862
|
+
async interruptSession(bridgeSessionId) {
|
|
2863
|
+
await this.options.runner.interruptTurn?.(bridgeSessionId);
|
|
2864
|
+
}
|
|
2865
|
+
async steerSession(bridgeSessionId, text) {
|
|
2866
|
+
await this.options.runner.steerTurn?.(bridgeSessionId, text);
|
|
2867
|
+
}
|
|
2868
|
+
};
|
|
2869
|
+
|
|
2870
|
+
// src/providers/defaultProviders.ts
|
|
2871
|
+
function createDefaultProviders(input = {}) {
|
|
2872
|
+
return [
|
|
2873
|
+
new ClaudeCodeProvider({ runner: new ClaudeStreamingRunner({ command: input.claudeCommand }) }),
|
|
2874
|
+
new CodexProvider({ runner: new CodexInteractiveRunner({ command: input.codexCommand }) })
|
|
2875
|
+
];
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2878
|
+
// src/providers/claude-code/claudeDetection.ts
|
|
2879
|
+
import { spawn as spawn4 } from "node:child_process";
|
|
2880
|
+
async function defaultCommandRunner(command, args) {
|
|
2881
|
+
return await new Promise((resolve) => {
|
|
2882
|
+
const child = spawn4(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2883
|
+
let stdout = "";
|
|
2884
|
+
let stderr = "";
|
|
2885
|
+
child.stdout.on("data", (chunk) => {
|
|
2886
|
+
stdout += String(chunk);
|
|
2887
|
+
});
|
|
2888
|
+
child.stderr.on("data", (chunk) => {
|
|
2889
|
+
stderr += String(chunk);
|
|
2890
|
+
});
|
|
2891
|
+
child.on("error", (error) => {
|
|
2892
|
+
resolve({ ok: false, code: error.code ?? "ERROR", stdout, stderr: stderr || error.message });
|
|
2893
|
+
});
|
|
2894
|
+
child.on("close", (code) => {
|
|
2895
|
+
if (code === 0) resolve({ ok: true, stdout, stderr });
|
|
2896
|
+
else resolve({ ok: false, code: code ?? "SIGNAL", stdout, stderr });
|
|
2897
|
+
});
|
|
2898
|
+
});
|
|
2899
|
+
}
|
|
2900
|
+
function parseClaudeVersion(output) {
|
|
2901
|
+
const match = output.match(/(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/);
|
|
2902
|
+
return match?.[1] ?? null;
|
|
2903
|
+
}
|
|
2904
|
+
async function detectClaudeCode(input = {}) {
|
|
2905
|
+
const runner = input.commandRunner ?? defaultCommandRunner;
|
|
2906
|
+
const result = await runner(input.command ?? "claude", ["--version"]);
|
|
2907
|
+
if (!result.ok) {
|
|
2908
|
+
return result.code === "ENOENT" ? { detected: false, reason: "missing_binary" } : { detected: false, reason: "command_failed" };
|
|
2909
|
+
}
|
|
2910
|
+
return { detected: true, version: parseClaudeVersion(`${result.stdout}
|
|
2911
|
+
${result.stderr}`) };
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
// src/providers/codex/codexDetection.ts
|
|
2915
|
+
import { spawn as spawn5 } from "node:child_process";
|
|
2916
|
+
async function defaultCodexCommandRunner(command, args) {
|
|
2917
|
+
return await new Promise((resolve) => {
|
|
2918
|
+
const child = spawn5(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
2919
|
+
let stdout = "";
|
|
2920
|
+
let stderr = "";
|
|
2921
|
+
child.stdout.on("data", (chunk) => {
|
|
2922
|
+
stdout += String(chunk);
|
|
2923
|
+
});
|
|
2924
|
+
child.stderr.on("data", (chunk) => {
|
|
2925
|
+
stderr += String(chunk);
|
|
2926
|
+
});
|
|
2927
|
+
child.on("error", (error) => {
|
|
2928
|
+
resolve({ ok: false, code: error.code ?? "ERROR", stdout, stderr: stderr || error.message });
|
|
2929
|
+
});
|
|
2930
|
+
child.on("close", (code) => {
|
|
2931
|
+
if (code === 0) resolve({ ok: true, stdout, stderr });
|
|
2932
|
+
else resolve({ ok: false, code: code ?? "SIGNAL", stdout, stderr });
|
|
2933
|
+
});
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
async function detectCodexCli(input = {}) {
|
|
2937
|
+
const runner = input.commandRunner ?? defaultCodexCommandRunner;
|
|
2938
|
+
const result = await runner(input.command ?? "codex", ["--version"]);
|
|
2939
|
+
if (!result.ok) {
|
|
2940
|
+
return result.code === "ENOENT" ? { detected: false, reason: "missing_binary" } : { detected: false, reason: "command_failed" };
|
|
2941
|
+
}
|
|
2942
|
+
return { detected: true, version: parseCodexVersion(`${result.stdout}
|
|
2943
|
+
${result.stderr}`) };
|
|
2944
|
+
}
|
|
2945
|
+
function parseCodexVersion(output) {
|
|
2946
|
+
const match = output.match(/(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/);
|
|
2947
|
+
return match?.[1] ?? null;
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
// src/providers/providerRegistry.ts
|
|
2951
|
+
var ProviderRegistry = class {
|
|
2952
|
+
constructor(options = {}) {
|
|
2953
|
+
this.options = options;
|
|
2954
|
+
}
|
|
2955
|
+
options;
|
|
2956
|
+
async getStatus() {
|
|
2957
|
+
const detectClaude = this.options.detectClaude ?? detectClaudeCode;
|
|
2958
|
+
const detectCodex = this.options.detectCodex ?? detectCodexCli;
|
|
2959
|
+
const checkedAt = this.options.now ? this.options.now() : Date.now();
|
|
2960
|
+
return {
|
|
2961
|
+
claude: {
|
|
2962
|
+
...await detectClaude({ command: this.options.claudeCommand }),
|
|
2963
|
+
command: this.options.claudeCommand,
|
|
2964
|
+
checkedAt
|
|
2965
|
+
},
|
|
2966
|
+
codex: {
|
|
2967
|
+
...await detectCodex({ command: this.options.codexCommand }),
|
|
2968
|
+
command: this.options.codexCommand,
|
|
2969
|
+
checkedAt
|
|
2970
|
+
}
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
};
|
|
2974
|
+
|
|
2975
|
+
// src/session/commandParser.ts
|
|
2976
|
+
function parseProvider(value) {
|
|
2977
|
+
if (value === "claude" || value === "claude-code") return "claude-code";
|
|
2978
|
+
if (value === "codex") return "codex";
|
|
2979
|
+
return null;
|
|
2980
|
+
}
|
|
2981
|
+
function looksLikePath(value) {
|
|
2982
|
+
return value.startsWith("/") || value.startsWith("~");
|
|
2983
|
+
}
|
|
2984
|
+
function parseBridgeCommand(input) {
|
|
2985
|
+
const text = input.trim();
|
|
2986
|
+
if (!text.startsWith("/")) return { kind: "chat", text };
|
|
2987
|
+
const [command, ...rest] = text.split(/\s+/);
|
|
2988
|
+
const first = rest[0];
|
|
2989
|
+
if (command === "/help") return { kind: "help" };
|
|
2990
|
+
if (command === "/status") return { kind: "status" };
|
|
2991
|
+
if (command === "/stop") return { kind: "cancel_generation" };
|
|
2992
|
+
if (command === "/sessions") {
|
|
2993
|
+
if (first === "mine") return { kind: "chat", text };
|
|
2994
|
+
let page = 1;
|
|
2995
|
+
let parts = rest;
|
|
2996
|
+
const last = rest.at(-1);
|
|
2997
|
+
if (last && /^p[1-9]\d*$/i.test(last)) {
|
|
2998
|
+
page = Number.parseInt(last.slice(1), 10);
|
|
2999
|
+
parts = rest.slice(0, -1);
|
|
3000
|
+
}
|
|
3001
|
+
const keyword = parts.join(" ").trim();
|
|
3002
|
+
return { kind: "list_sessions", scope: "all", keyword: keyword || null, page };
|
|
3003
|
+
}
|
|
3004
|
+
if (command === "/resume") {
|
|
3005
|
+
return { kind: "resume_session", ref: first ?? "" };
|
|
3006
|
+
}
|
|
3007
|
+
if (command === "/new") {
|
|
3008
|
+
if (!first) return { kind: "new_session", providerId: null, cwd: null };
|
|
3009
|
+
const colonIndex = first.indexOf(":");
|
|
3010
|
+
if (colonIndex !== -1) {
|
|
3011
|
+
const providerId2 = parseProvider(first.slice(0, colonIndex));
|
|
3012
|
+
const cwd = first.slice(colonIndex + 1);
|
|
3013
|
+
if (providerId2 && looksLikePath(cwd)) return { kind: "new_session", providerId: providerId2, cwd };
|
|
3014
|
+
return { kind: "chat", text };
|
|
3015
|
+
}
|
|
3016
|
+
const providerId = parseProvider(first);
|
|
3017
|
+
if (providerId) return { kind: "new_session", providerId, cwd: null };
|
|
3018
|
+
if (looksLikePath(first)) return { kind: "new_session", providerId: null, cwd: first };
|
|
3019
|
+
return { kind: "chat", text };
|
|
3020
|
+
}
|
|
3021
|
+
return { kind: "chat", text };
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
// src/shared/bridgeCommandHelp.ts
|
|
3025
|
+
var BRIDGE_COMMAND_HELP_INTRO = "\u76F4\u63A5\u53D1\u9001\u6587\u5B57\u5373\u53EF\u4E0E AI \u5BF9\u8BDD\u3002";
|
|
3026
|
+
var BRIDGE_COMMAND_HELP_GROUPS = [
|
|
3027
|
+
{
|
|
3028
|
+
title: "\u4F1A\u8BDD\u7BA1\u7406",
|
|
3029
|
+
entries: [
|
|
3030
|
+
{ command: "/help", desc: "\u663E\u793A\u672C\u5E2E\u52A9" },
|
|
3031
|
+
{ command: "/status", desc: "\u67E5\u770B\u5F53\u524D\u4F1A\u8BDD\uFF08provider\u3001\u5DE5\u4F5C\u76EE\u5F55\u3001\u72B6\u6001\uFF09" },
|
|
3032
|
+
{ command: "/new", desc: "\u65B0\u5EFA\u9ED8\u8BA4 provider \u4F1A\u8BDD" },
|
|
3033
|
+
{ command: "/new claude|codex", desc: "\u65B0\u5EFA\u6307\u5B9A provider \u4F1A\u8BDD" },
|
|
3034
|
+
{ command: "/new <\u76EE\u5F55>", desc: "\u5728\u6307\u5B9A\u76EE\u5F55\u65B0\u5EFA\u9ED8\u8BA4 provider \u4F1A\u8BDD" },
|
|
3035
|
+
{ command: "/new claude:<\u76EE\u5F55>", desc: "\u65B0\u5EFA\u6307\u5B9A provider + \u6307\u5B9A\u76EE\u5F55\u4F1A\u8BDD" },
|
|
3036
|
+
{ command: "/stop", desc: "\u4E2D\u65AD\u5F53\u524D\u6B63\u5728\u751F\u6210\u7684\u56DE\u590D\uFF08\u4F1A\u8BDD\u4FDD\u7559\uFF09" }
|
|
3037
|
+
]
|
|
3038
|
+
},
|
|
3039
|
+
{
|
|
3040
|
+
title: "\u5386\u53F2\u4F1A\u8BDD",
|
|
3041
|
+
entries: [
|
|
3042
|
+
{ command: "/sessions", desc: "\u5217\u51FA\u6700\u8FD1\u7684\u5386\u53F2\u4F1A\u8BDD\u7B2C 1 \u9875\uFF08\u6309\u66F4\u65B0\u65F6\u95F4\u5012\u5E8F\uFF09" },
|
|
3043
|
+
{ command: "/sessions p2", desc: "\u67E5\u770B\u7B2C 2 \u9875\uFF1B\u9875\u7801\u8BED\u6CD5\u56FA\u5B9A\u4E3A `p<number>`" },
|
|
3044
|
+
{ command: "/sessions <\u5173\u952E\u8BCD>", desc: "\u6309\u6807\u9898/\u76EE\u5F55\u7B5B\u9009\uFF0C\u7B2C 1 \u9875" },
|
|
3045
|
+
{ command: "/sessions <\u5173\u952E\u8BCD> p2", desc: "\u67E5\u770B\u7B5B\u9009\u7ED3\u679C\u7684\u7B2C 2 \u9875" },
|
|
3046
|
+
{ command: "/resume <\u7F16\u53F7>", desc: "\u6309 /sessions \u5217\u8868\u7F16\u53F7\u6062\u590D\uFF08\u4E5F\u53EF\u76F4\u63A5\u7528\u4F1A\u8BDD id\uFF09" }
|
|
3047
|
+
]
|
|
3048
|
+
}
|
|
3049
|
+
];
|
|
3050
|
+
function buildBridgeCommandHelpMarkdown() {
|
|
3051
|
+
const lines = ["**\u53EF\u7528\u547D\u4EE4**", "", BRIDGE_COMMAND_HELP_INTRO];
|
|
3052
|
+
for (const group of BRIDGE_COMMAND_HELP_GROUPS) {
|
|
3053
|
+
lines.push("", `**${group.title}**${group.note ? `\uFF08${group.note}\uFF09` : ""}`);
|
|
3054
|
+
for (const entry of group.entries) {
|
|
3055
|
+
lines.push(`- \`${entry.command}\` \u2014 ${entry.desc}`);
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
return lines.join("\n");
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// src/session/currentConversationStore.ts
|
|
3062
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3063
|
+
import { dirname as dirname6 } from "node:path";
|
|
3064
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
3065
|
+
var CurrentConversationStore = class {
|
|
3066
|
+
constructor(configPath, defaults) {
|
|
3067
|
+
this.configPath = configPath;
|
|
3068
|
+
this.defaults = defaults;
|
|
3069
|
+
}
|
|
3070
|
+
configPath;
|
|
3071
|
+
defaults;
|
|
3072
|
+
getCurrent() {
|
|
3073
|
+
return this.readState().bridge?.activeWeChatUser?.currentConversation ?? null;
|
|
3074
|
+
}
|
|
3075
|
+
getActiveSession(chatId) {
|
|
3076
|
+
const current = this.getCurrent();
|
|
3077
|
+
return current?.chatId === chatId ? current : null;
|
|
3078
|
+
}
|
|
3079
|
+
listSessions() {
|
|
3080
|
+
const current = this.getCurrent();
|
|
3081
|
+
return current ? [current] : [];
|
|
3082
|
+
}
|
|
3083
|
+
create(input) {
|
|
3084
|
+
const now = Date.now();
|
|
3085
|
+
const record = {
|
|
3086
|
+
id: `bs_${nanoid2(10)}`,
|
|
3087
|
+
chatId: input.chatId,
|
|
3088
|
+
ownerUserId: input.ownerUserId,
|
|
3089
|
+
providerId: input.providerId ?? this.defaults.defaultProviderId,
|
|
3090
|
+
cwd: input.cwd ?? this.defaults.defaultCwd,
|
|
3091
|
+
recoverySource: input.recoverySource ?? "runtime",
|
|
3092
|
+
resumeTitle: input.resumeTitle,
|
|
3093
|
+
status: "starting",
|
|
3094
|
+
createdAt: now,
|
|
3095
|
+
lastActivityAt: now
|
|
3096
|
+
};
|
|
3097
|
+
this.writeCurrent(record);
|
|
3098
|
+
return record;
|
|
3099
|
+
}
|
|
3100
|
+
setCurrent(record) {
|
|
3101
|
+
this.writeCurrent(record);
|
|
3102
|
+
return record;
|
|
3103
|
+
}
|
|
3104
|
+
update(patch, expectId) {
|
|
3105
|
+
const existing = this.getCurrent();
|
|
3106
|
+
if (!existing) return null;
|
|
3107
|
+
if (expectId && existing.id !== expectId) return null;
|
|
3108
|
+
const next = { ...existing, ...patch };
|
|
3109
|
+
this.writeCurrent(next);
|
|
3110
|
+
return next;
|
|
3111
|
+
}
|
|
3112
|
+
clear() {
|
|
3113
|
+
const state = this.readState();
|
|
3114
|
+
const activeWeChatUser = state.bridge?.activeWeChatUser;
|
|
3115
|
+
state.bridge = {
|
|
3116
|
+
...state.bridge ?? {},
|
|
3117
|
+
activeWeChatUser: activeWeChatUser ? { ...activeWeChatUser, currentConversation: void 0 } : void 0
|
|
3118
|
+
};
|
|
3119
|
+
this.writeState(state);
|
|
3120
|
+
}
|
|
3121
|
+
readState() {
|
|
3122
|
+
if (!existsSync5(this.configPath)) return {};
|
|
3123
|
+
const raw = JSON.parse(readFileSync3(this.configPath, "utf8"));
|
|
3124
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
3125
|
+
}
|
|
3126
|
+
writeCurrent(record) {
|
|
3127
|
+
const normalized = { ...record, cwd: expandTilde(record.cwd) ?? record.cwd };
|
|
3128
|
+
const state = this.readState();
|
|
3129
|
+
const activeWeChatUser = state.bridge?.activeWeChatUser;
|
|
3130
|
+
state.bridge = {
|
|
3131
|
+
...state.bridge ?? {},
|
|
3132
|
+
activeWeChatUser: activeWeChatUser ? { ...activeWeChatUser, currentConversation: normalized } : {
|
|
3133
|
+
id: `user_${nanoid2(10)}`,
|
|
3134
|
+
platform: "weixin",
|
|
3135
|
+
platformUserId: normalized.chatId,
|
|
3136
|
+
role: "user",
|
|
3137
|
+
createdAt: normalized.createdAt,
|
|
3138
|
+
updatedAt: normalized.lastActivityAt,
|
|
3139
|
+
currentConversation: normalized
|
|
3140
|
+
}
|
|
3141
|
+
};
|
|
3142
|
+
this.writeState(state);
|
|
3143
|
+
}
|
|
3144
|
+
writeState(state) {
|
|
3145
|
+
mkdirSync3(dirname6(this.configPath), { recursive: true });
|
|
3146
|
+
writeFileSync3(this.configPath, `${JSON.stringify(state, null, 2)}
|
|
3147
|
+
`, "utf8");
|
|
3148
|
+
}
|
|
3149
|
+
};
|
|
3150
|
+
|
|
3151
|
+
// src/session/messageRouter.ts
|
|
3152
|
+
var MessageRouter = class _MessageRouter {
|
|
3153
|
+
constructor(options) {
|
|
3154
|
+
this.options = options;
|
|
3155
|
+
for (const provider of options.providers) this.providers.set(provider.id, provider);
|
|
3156
|
+
if (!this.options.conversation) {
|
|
3157
|
+
const fallbackSessions = this.options.sessions;
|
|
3158
|
+
this.options.conversation = fallbackSessions?.store ? fallbackSessions.store : new CurrentConversationStore(statePath("claude-codex-wechat-message-router.json"), {
|
|
3159
|
+
defaultCwd: defaultWorkspaceDir(),
|
|
3160
|
+
defaultProviderId: "claude-code"
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
options;
|
|
3165
|
+
static SESSION_LIST_LIMIT = 8;
|
|
3166
|
+
providers = /* @__PURE__ */ new Map();
|
|
3167
|
+
sessionListCache = /* @__PURE__ */ new Map();
|
|
3168
|
+
// Single global lock: the bridge stores one current conversation, so every
|
|
3169
|
+
// session-mutating operation (chat generation + state-changing commands) must
|
|
3170
|
+
// run one at a time. /stop and read-only commands bypass this lock.
|
|
3171
|
+
sessionOpChain = Promise.resolve();
|
|
3172
|
+
// Commands run on their own chain, independent of sessionOpChain, so a hung
|
|
3173
|
+
// generation can never block /new, /stop, etc.
|
|
3174
|
+
commandChain = Promise.resolve();
|
|
3175
|
+
generationSeq = 0;
|
|
3176
|
+
// 每条消息到达时按顺序分配一个全局序号;latestMutatingSeq 记录每个 chat
|
|
3177
|
+
// 最近一次会话变更命令的序号。聊天用它判断自己是否已被更晚到达的命令取代,
|
|
3178
|
+
// 从而消除「命令从 commandChain 插队到排队中的聊天前面」导致的串话。
|
|
3179
|
+
opSeq = 0;
|
|
3180
|
+
latestMutatingSeq = /* @__PURE__ */ new Map();
|
|
3181
|
+
activeGenerations = /* @__PURE__ */ new Map();
|
|
3182
|
+
get conversation() {
|
|
3183
|
+
return this.options.conversation;
|
|
3184
|
+
}
|
|
3185
|
+
/**
|
|
3186
|
+
* Single outbound exit. With a quota gate (WeChat), messages flow through it
|
|
3187
|
+
* (sent now, hinted, or queued). Without a gate, sent directly.
|
|
3188
|
+
*/
|
|
3189
|
+
async sendToChat(message) {
|
|
3190
|
+
if (this.options.outboundGate) {
|
|
3191
|
+
await this.options.outboundGate.deliver(message.chatId, { kind: message.kind, text: message.text });
|
|
3192
|
+
} else {
|
|
3193
|
+
await this.options.channel.sendMessage(message);
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
conversationSignature() {
|
|
3197
|
+
const current = this.conversation.getCurrent();
|
|
3198
|
+
if (!current) return "";
|
|
3199
|
+
return `${current.id}|${current.providerId}|${current.providerSessionId ?? ""}|${current.cwd}|${current.status}`;
|
|
3200
|
+
}
|
|
3201
|
+
async withSessionChangeNotification(run) {
|
|
3202
|
+
const before = this.conversationSignature();
|
|
3203
|
+
try {
|
|
3204
|
+
return await run();
|
|
3205
|
+
} finally {
|
|
3206
|
+
if (this.conversationSignature() !== before) {
|
|
3207
|
+
this.options.events?.emit({ type: "channel.current-session-changed" });
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
}
|
|
3211
|
+
async handleMessage(message) {
|
|
3212
|
+
return this.withSessionChangeNotification(() => this.handleMessageInner(message));
|
|
3213
|
+
}
|
|
3214
|
+
async handleMessageInner(message) {
|
|
3215
|
+
let user = this.options.resolveUser(message);
|
|
3216
|
+
if (!user) {
|
|
3217
|
+
user = this.options.autoAuthorizeUser?.(message) ?? null;
|
|
3218
|
+
}
|
|
3219
|
+
if (!user) {
|
|
3220
|
+
return { status: "pairing_required", code: "pairing_required" };
|
|
3221
|
+
}
|
|
3222
|
+
if (this.options.outboundGate?.hasPending(message.chatId)) {
|
|
3223
|
+
await this.options.outboundGate.drain(message.chatId);
|
|
3224
|
+
return { status: "accepted" };
|
|
3225
|
+
}
|
|
3226
|
+
if (this.options.outboundGate?.shouldInterceptReply?.(message.chatId)) {
|
|
3227
|
+
return { status: "accepted" };
|
|
3228
|
+
}
|
|
3229
|
+
const composedText = composeInboundText(message.content);
|
|
3230
|
+
if (!composedText) return { status: "accepted" };
|
|
3231
|
+
const isPlainText = message.content.type === "text" && !message.content.attachments?.length && !message.content.quoted;
|
|
3232
|
+
const command = isPlainText ? parseBridgeCommand(message.content.text ?? "") : { kind: "chat", text: composedText };
|
|
3233
|
+
const seq = ++this.opSeq;
|
|
3234
|
+
if (command.kind !== "chat") {
|
|
3235
|
+
if (!isImmediateCommand(command.kind)) {
|
|
3236
|
+
this.latestMutatingSeq.set(message.chatId, seq);
|
|
3237
|
+
}
|
|
3238
|
+
const runWithTyping = async () => {
|
|
3239
|
+
await this.options.channel.setTyping?.(message.chatId, true);
|
|
3240
|
+
try {
|
|
3241
|
+
await this.handleCommand(message.chatId, user, command);
|
|
3242
|
+
} finally {
|
|
3243
|
+
await Promise.resolve(this.options.outboundGate?.finalize?.(message.chatId)).catch(() => void 0);
|
|
3244
|
+
await this.options.channel.setTyping?.(message.chatId, false);
|
|
3245
|
+
}
|
|
3246
|
+
};
|
|
3247
|
+
if (isImmediateCommand(command.kind)) {
|
|
3248
|
+
await runWithTyping();
|
|
3249
|
+
} else {
|
|
3250
|
+
await this.runOnCommandChain(runWithTyping);
|
|
3251
|
+
}
|
|
3252
|
+
return { status: "accepted" };
|
|
3253
|
+
}
|
|
3254
|
+
if ((this.latestMutatingSeq.get(message.chatId) ?? 0) > seq) {
|
|
3255
|
+
return { status: "accepted" };
|
|
3256
|
+
}
|
|
3257
|
+
if (await this.maybeSteer(message.chatId, command.text)) {
|
|
3258
|
+
return { status: "accepted" };
|
|
3259
|
+
}
|
|
3260
|
+
await this.runExclusive(() => this.runChatGeneration(message, user, command.text, seq));
|
|
3261
|
+
return { status: "accepted" };
|
|
3262
|
+
}
|
|
3263
|
+
// A chat message arriving while a turn is in flight: if the provider supports
|
|
3264
|
+
// native steer (Codex turn/steer), inject it into the running turn instead of
|
|
3265
|
+
// queueing a new turn. Otherwise fall back to the serialized lock (queue).
|
|
3266
|
+
maybeSteer(chatId, text) {
|
|
3267
|
+
const active = this.activeGenerations.get(chatId);
|
|
3268
|
+
if (!active) return Promise.resolve(false);
|
|
3269
|
+
const provider = this.providers.get(active.providerId);
|
|
3270
|
+
if (!provider?.steerSession) return Promise.resolve(false);
|
|
3271
|
+
return provider.steerSession(active.bridgeSessionId, text).then(() => true);
|
|
3272
|
+
}
|
|
3273
|
+
// Serialize session-mutating work onto one global chain. The chain is updated
|
|
3274
|
+
// synchronously (no await before the assignment) so two non-blocking channel
|
|
3275
|
+
// dispatches enqueue in arrival order without racing.
|
|
3276
|
+
runExclusive(run) {
|
|
3277
|
+
const result = this.sessionOpChain.then(() => run());
|
|
3278
|
+
this.sessionOpChain = result.then(() => void 0, () => void 0);
|
|
3279
|
+
return result;
|
|
3280
|
+
}
|
|
3281
|
+
// Commands serialize among themselves on a chain that is independent of the
|
|
3282
|
+
// generation chain, so they never wait behind a (possibly hung) generation.
|
|
3283
|
+
runOnCommandChain(run) {
|
|
3284
|
+
const result = this.commandChain.then(() => run());
|
|
3285
|
+
this.commandChain = result.then(() => void 0, () => void 0);
|
|
3286
|
+
return result;
|
|
3287
|
+
}
|
|
3288
|
+
// Abandon the live generation for a chat before a command swaps/clears the
|
|
3289
|
+
// current session. Signalling abort makes the generation loop release
|
|
3290
|
+
// sessionOpChain immediately even if the provider can't be interrupted (a hung
|
|
3291
|
+
// CLI or a provider without interruptSession), so later chats never queue
|
|
3292
|
+
// forever behind it. Interrupting the provider is then best-effort cleanup.
|
|
3293
|
+
async preemptActiveGeneration(chatId) {
|
|
3294
|
+
const active = this.activeGenerations.get(chatId);
|
|
3295
|
+
if (!active) return;
|
|
3296
|
+
this.activeGenerations.delete(chatId);
|
|
3297
|
+
active.abort();
|
|
3298
|
+
void Promise.resolve(this.providers.get(active.providerId)?.interruptSession?.(active.bridgeSessionId)).catch(() => void 0);
|
|
3299
|
+
}
|
|
3300
|
+
async runChatGeneration(message, user, text, seq) {
|
|
3301
|
+
const superseded = () => (this.latestMutatingSeq.get(message.chatId) ?? 0) > seq;
|
|
3302
|
+
if (superseded()) return;
|
|
3303
|
+
const sessionResumeTitle = buildSessionBridgeName({
|
|
3304
|
+
platform: "weixin",
|
|
3305
|
+
platformUserId: message.user.id,
|
|
3306
|
+
chatId: message.chatId,
|
|
3307
|
+
summary: resumeTitleFromContent(message.content)
|
|
3308
|
+
});
|
|
3309
|
+
let session = this.conversation.getCurrent();
|
|
3310
|
+
if (!session) {
|
|
3311
|
+
if (superseded()) return;
|
|
3312
|
+
const attached = await this.options.autoAttachSession?.(message, user, { shouldCommit: () => !superseded() });
|
|
3313
|
+
if (superseded()) return;
|
|
3314
|
+
session = this.conversation.getCurrent() ?? attached ?? this.conversation.create({
|
|
3315
|
+
chatId: message.chatId,
|
|
3316
|
+
ownerUserId: user.id,
|
|
3317
|
+
...this.options.defaults?.defaultProvider ? { providerId: this.options.defaults.defaultProvider } : {},
|
|
3318
|
+
...this.options.defaults?.defaultWorkspace ? { cwd: this.options.defaults.defaultWorkspace } : {},
|
|
3319
|
+
resumeTitle: sessionResumeTitle
|
|
3320
|
+
});
|
|
3321
|
+
}
|
|
3322
|
+
const provider = this.providers.get(session.providerId);
|
|
3323
|
+
if (!provider) throw new Error(`provider_not_registered:${session.providerId}`);
|
|
3324
|
+
if (!session.providerSessionId) {
|
|
3325
|
+
const resumeTitle = session.resumeTitle ?? sessionResumeTitle;
|
|
3326
|
+
this.conversation.update({
|
|
3327
|
+
resumeTitle,
|
|
3328
|
+
lastActivityAt: Date.now()
|
|
3329
|
+
}, session.id);
|
|
3330
|
+
const providerSession = await provider.startSession({
|
|
3331
|
+
bridgeSessionId: session.id,
|
|
3332
|
+
cwd: session.cwd,
|
|
3333
|
+
options: {
|
|
3334
|
+
sessionName: resumeTitle
|
|
3335
|
+
}
|
|
3336
|
+
});
|
|
3337
|
+
const updated = this.conversation.update({
|
|
3338
|
+
providerSessionId: providerSession.providerSessionId,
|
|
3339
|
+
resumeTitle,
|
|
3340
|
+
status: providerSession.status,
|
|
3341
|
+
lastActivityAt: Date.now()
|
|
3342
|
+
}, session.id) ?? session;
|
|
3343
|
+
if (updated.providerId === "codex" && updated.providerSessionId && updated.resumeTitle) {
|
|
3344
|
+
await upsertCodexSessionIndexEntry({
|
|
3345
|
+
sessionId: updated.providerSessionId,
|
|
3346
|
+
threadName: updated.resumeTitle
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
await this.persistBridgeMetadata(updated);
|
|
3350
|
+
}
|
|
3351
|
+
const genId = ++this.generationSeq;
|
|
3352
|
+
let abortGeneration;
|
|
3353
|
+
const aborted = new Promise((resolve) => {
|
|
3354
|
+
abortGeneration = () => resolve("aborted");
|
|
3355
|
+
});
|
|
3356
|
+
this.activeGenerations.set(message.chatId, {
|
|
3357
|
+
genId,
|
|
3358
|
+
providerId: session.providerId,
|
|
3359
|
+
bridgeSessionId: session.id,
|
|
3360
|
+
abort: abortGeneration
|
|
3361
|
+
});
|
|
3362
|
+
const isLive = () => this.activeGenerations.get(message.chatId)?.genId === genId && (this.latestMutatingSeq.get(message.chatId) ?? 0) <= seq;
|
|
3363
|
+
let bufferedText = "";
|
|
3364
|
+
const typingMinIntervalMs = this.options.typingKeepaliveMs ?? 5e3;
|
|
3365
|
+
let lastTypingAt = Date.now();
|
|
3366
|
+
await this.options.channel.setTyping?.(message.chatId, true);
|
|
3367
|
+
const refreshTyping = () => {
|
|
3368
|
+
const now = Date.now();
|
|
3369
|
+
if (now - lastTypingAt < typingMinIntervalMs) return;
|
|
3370
|
+
lastTypingAt = now;
|
|
3371
|
+
void this.options.channel.setTyping?.(message.chatId, true);
|
|
3372
|
+
};
|
|
3373
|
+
let iterator = null;
|
|
3374
|
+
try {
|
|
3375
|
+
iterator = provider.sendMessage({ bridgeSessionId: session.id, text })[Symbol.asyncIterator]();
|
|
3376
|
+
while (isLive()) {
|
|
3377
|
+
const step = await Promise.race([iterator.next(), aborted]);
|
|
3378
|
+
if (step === "aborted" || step.done) break;
|
|
3379
|
+
if (!isLive()) break;
|
|
3380
|
+
refreshTyping();
|
|
3381
|
+
const event = step.value;
|
|
3382
|
+
if (event.type === "text_delta" && event.text) {
|
|
3383
|
+
bufferedText += event.text;
|
|
3384
|
+
}
|
|
3385
|
+
if (event.type === "message_done" && bufferedText.trim()) {
|
|
3386
|
+
await this.sendToChat({ chatId: message.chatId, kind: "text", text: bufferedText });
|
|
3387
|
+
bufferedText = "";
|
|
3388
|
+
}
|
|
3389
|
+
if (event.type === "session_state") {
|
|
3390
|
+
const updated = this.conversation.update({
|
|
3391
|
+
providerSessionId: event.state.providerSessionId,
|
|
3392
|
+
status: event.state.status,
|
|
3393
|
+
lastActivityAt: Date.now()
|
|
3394
|
+
}, session.id) ?? session;
|
|
3395
|
+
if (updated.providerId === "codex" && updated.providerSessionId && updated.resumeTitle) {
|
|
3396
|
+
await upsertCodexSessionIndexEntry({
|
|
3397
|
+
sessionId: updated.providerSessionId,
|
|
3398
|
+
threadName: updated.resumeTitle
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
3401
|
+
await this.persistBridgeMetadata(updated);
|
|
3402
|
+
}
|
|
3403
|
+
if (event.type === "error" && event.error) {
|
|
3404
|
+
if (bufferedText.trim()) {
|
|
3405
|
+
await this.sendToChat({ chatId: message.chatId, kind: "text", text: bufferedText });
|
|
3406
|
+
bufferedText = "";
|
|
3407
|
+
}
|
|
3408
|
+
const errorText = `Provider error: ${event.error}`;
|
|
3409
|
+
await this.sendToChat({
|
|
3410
|
+
chatId: message.chatId,
|
|
3411
|
+
kind: "status",
|
|
3412
|
+
text: errorText
|
|
3413
|
+
});
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
if (isLive() && bufferedText.trim()) {
|
|
3417
|
+
await this.sendToChat({ chatId: message.chatId, kind: "text", text: bufferedText });
|
|
3418
|
+
bufferedText = "";
|
|
3419
|
+
}
|
|
3420
|
+
if (isLive()) {
|
|
3421
|
+
const finalBinding = this.conversation.getCurrent();
|
|
3422
|
+
if (finalBinding?.id === session.id && finalBinding.providerSessionId) {
|
|
3423
|
+
await this.persistBridgeMetadata(finalBinding).catch(() => void 0);
|
|
3424
|
+
}
|
|
3425
|
+
}
|
|
3426
|
+
} finally {
|
|
3427
|
+
void iterator?.return?.().catch(() => void 0);
|
|
3428
|
+
const stillMine = this.activeGenerations.get(message.chatId)?.genId === genId;
|
|
3429
|
+
if (stillMine) this.activeGenerations.delete(message.chatId);
|
|
3430
|
+
await Promise.resolve(this.options.outboundGate?.finalize?.(message.chatId)).catch(() => void 0);
|
|
3431
|
+
if (stillMine) await this.options.channel.setTyping?.(message.chatId, false);
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
async handleCommand(chatId, user, command) {
|
|
3435
|
+
if (command.kind === "help") {
|
|
3436
|
+
await this.sendToChat({
|
|
3437
|
+
chatId,
|
|
3438
|
+
kind: "markdown",
|
|
3439
|
+
text: buildBridgeCommandHelpMarkdown()
|
|
3440
|
+
});
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
if (command.kind === "cancel_generation") {
|
|
3444
|
+
const active = this.activeGenerations.get(chatId);
|
|
3445
|
+
if (!active) {
|
|
3446
|
+
await this.sendToChat({ chatId, kind: "status", text: "\u5F53\u524D\u6CA1\u6709\u6B63\u5728\u8FDB\u884C\u7684\u751F\u6210" });
|
|
3447
|
+
return;
|
|
3448
|
+
}
|
|
3449
|
+
const provider = this.providers.get(active.providerId);
|
|
3450
|
+
if (!provider?.interruptSession) {
|
|
3451
|
+
await this.sendToChat({ chatId, kind: "status", text: `${active.providerId} \u6682\u4E0D\u652F\u6301\u4E2D\u65AD` });
|
|
3452
|
+
return;
|
|
3453
|
+
}
|
|
3454
|
+
await provider.interruptSession(active.bridgeSessionId);
|
|
3455
|
+
await this.sendToChat({ chatId, kind: "status", text: "\u5DF2\u4E2D\u65AD\u5F53\u524D\u751F\u6210\uFF0C\u4F1A\u8BDD\u4FDD\u7559" });
|
|
3456
|
+
return;
|
|
3457
|
+
}
|
|
3458
|
+
if (command.kind === "new_session") {
|
|
3459
|
+
await this.preemptActiveGeneration(chatId);
|
|
3460
|
+
const providerId = command.providerId ?? this.options.defaults?.defaultProvider ?? "claude-code";
|
|
3461
|
+
const cwd = command.cwd ?? this.conversation.getCurrent()?.cwd ?? this.options.defaults?.defaultWorkspace ?? defaultWorkspaceDir();
|
|
3462
|
+
const session = this.conversation.create({
|
|
3463
|
+
chatId,
|
|
3464
|
+
ownerUserId: user.id,
|
|
3465
|
+
providerId,
|
|
3466
|
+
cwd
|
|
3467
|
+
});
|
|
3468
|
+
await this.sendToChat({
|
|
3469
|
+
chatId,
|
|
3470
|
+
kind: "status",
|
|
3471
|
+
text: `Started new ${providerId} session: ${session.id}`
|
|
3472
|
+
});
|
|
3473
|
+
return;
|
|
3474
|
+
}
|
|
3475
|
+
if (command.kind === "status") {
|
|
3476
|
+
const session = this.conversation.getCurrent();
|
|
3477
|
+
await this.sendToChat({
|
|
3478
|
+
chatId,
|
|
3479
|
+
kind: "status",
|
|
3480
|
+
text: session ? `Active session ${session.id} \xB7 ${session.providerId} \xB7 ${session.cwd} \xB7 ${session.status}` : "No active session"
|
|
3481
|
+
});
|
|
3482
|
+
return;
|
|
3483
|
+
}
|
|
3484
|
+
if (command.kind === "list_sessions") {
|
|
3485
|
+
const current = this.conversation.getCurrent();
|
|
3486
|
+
const providerId = current?.providerId ?? this.options.defaults?.defaultProvider ?? "claude-code";
|
|
3487
|
+
const provider = this.providers.get(providerId);
|
|
3488
|
+
if (!provider?.listRecoverableSessions) {
|
|
3489
|
+
await this.sendToChat({ chatId, kind: "status", text: `\u5F53\u524D provider\uFF08${providerId}\uFF09\u4E0D\u652F\u6301\u4F1A\u8BDD\u5217\u8868` });
|
|
3490
|
+
return;
|
|
3491
|
+
}
|
|
3492
|
+
let candidates = await listUnattachedRecoverableSessions({ provider, providerId, currentSession: current });
|
|
3493
|
+
if (command.keyword) {
|
|
3494
|
+
const keyword = command.keyword.toLowerCase();
|
|
3495
|
+
candidates = candidates.filter(
|
|
3496
|
+
(candidate) => (candidate.resumeTitle ?? candidate.title ?? "").toLowerCase().includes(keyword) || (candidate.cwd ?? "").toLowerCase().includes(keyword)
|
|
3497
|
+
);
|
|
3498
|
+
}
|
|
3499
|
+
candidates.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
3500
|
+
const totalPages = Math.max(1, Math.ceil(candidates.length / _MessageRouter.SESSION_LIST_LIMIT));
|
|
3501
|
+
const page = Math.min(command.page, totalPages);
|
|
3502
|
+
const start = (page - 1) * _MessageRouter.SESSION_LIST_LIMIT;
|
|
3503
|
+
const shown = candidates.slice(start, start + _MessageRouter.SESSION_LIST_LIMIT);
|
|
3504
|
+
if (shown.length === 0) {
|
|
3505
|
+
await this.sendToChat({ chatId, kind: "status", text: "\u6CA1\u6709\u53EF\u6062\u590D\u7684\u4F1A\u8BDD" });
|
|
3506
|
+
return;
|
|
3507
|
+
}
|
|
3508
|
+
this.sessionListCache.set(chatId, { providerId, ids: shown.map((candidate) => candidate.id) });
|
|
3509
|
+
const lines = [`**\u53EF\u6062\u590D\u4F1A\u8BDD\uFF08${providerId}\uFF09**`, `\u7B2C ${page}/${totalPages} \u9875`, ""];
|
|
3510
|
+
shown.forEach((candidate, index) => {
|
|
3511
|
+
lines.push(`${index + 1}. ${this.formatSessionLine(candidate)}`);
|
|
3512
|
+
});
|
|
3513
|
+
if (page < totalPages) {
|
|
3514
|
+
const nextPageCommand = command.keyword ? `/sessions ${command.keyword} p${page + 1}` : `/sessions p${page + 1}`;
|
|
3515
|
+
lines.push("", `\u56DE\u590D \`${nextPageCommand}\` \u67E5\u770B\u4E0B\u4E00\u9875`);
|
|
3516
|
+
} else if (candidates.length > shown.length) {
|
|
3517
|
+
lines.push("", `\uFF08\u8FD8\u6709 ${candidates.length - shown.length} \u6761\uFF0C\u7528 \`/sessions <\u5173\u952E\u8BCD>\` \u7B5B\u9009\uFF09`);
|
|
3518
|
+
}
|
|
3519
|
+
lines.push("", "\u56DE\u590D `/resume <\u7F16\u53F7>` \u6062\u590D");
|
|
3520
|
+
await this.sendToChat({ chatId, kind: "markdown", text: lines.join("\n") });
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
if (command.kind === "resume_session") {
|
|
3524
|
+
const ref = command.ref.trim();
|
|
3525
|
+
if (!ref) {
|
|
3526
|
+
await this.sendToChat({ chatId, kind: "status", text: "\u7528\u6CD5\uFF1A/resume <\u7F16\u53F7>" });
|
|
3527
|
+
return;
|
|
3528
|
+
}
|
|
3529
|
+
await this.preemptActiveGeneration(chatId);
|
|
3530
|
+
let providerId = this.conversation.getCurrent()?.providerId ?? this.options.defaults?.defaultProvider ?? "claude-code";
|
|
3531
|
+
let resolvedId = ref;
|
|
3532
|
+
if (/^\d+$/.test(ref)) {
|
|
3533
|
+
const cached = this.sessionListCache.get(chatId);
|
|
3534
|
+
if (!cached) {
|
|
3535
|
+
await this.sendToChat({ chatId, kind: "status", text: "\u8BF7\u5148\u7528 /sessions \u5217\u51FA\u4F1A\u8BDD\uFF0C\u518D\u7528 /resume <\u7F16\u53F7> \u6062\u590D" });
|
|
3536
|
+
return;
|
|
3537
|
+
}
|
|
3538
|
+
const target = cached.ids[Number(ref) - 1];
|
|
3539
|
+
if (!target) {
|
|
3540
|
+
await this.sendToChat({ chatId, kind: "status", text: `\u7F16\u53F7 ${ref} \u8D85\u51FA\u8303\u56F4\uFF0C\u8BF7\u7528 /sessions \u91CD\u65B0\u5217\u51FA` });
|
|
3541
|
+
return;
|
|
3542
|
+
}
|
|
3543
|
+
resolvedId = target;
|
|
3544
|
+
providerId = cached.providerId;
|
|
3545
|
+
}
|
|
3546
|
+
const previousCwd = this.conversation.getCurrent()?.cwd;
|
|
3547
|
+
const provider = this.providers.get(providerId);
|
|
3548
|
+
if (!provider?.attachSession || !provider.listRecoverableSessions) {
|
|
3549
|
+
await this.sendToChat({ chatId, kind: "status", text: `\u5F53\u524D provider\uFF08${providerId}\uFF09\u4E0D\u652F\u6301\u4F1A\u8BDD\u6062\u590D` });
|
|
3550
|
+
return;
|
|
3551
|
+
}
|
|
3552
|
+
const candidate = (await provider.listRecoverableSessions()).find((item) => item.id === resolvedId);
|
|
3553
|
+
if (!candidate) {
|
|
3554
|
+
await this.sendToChat({ chatId, kind: "status", text: `\u627E\u4E0D\u5230\u4F1A\u8BDD ${ref}` });
|
|
3555
|
+
return;
|
|
3556
|
+
}
|
|
3557
|
+
const cwdUnresolved = !candidate.cwd;
|
|
3558
|
+
const attached = await attachProviderSessionToBridge({
|
|
3559
|
+
conversationStore: this.conversation,
|
|
3560
|
+
lastProviderSessions: this.options.lastProviderSessions,
|
|
3561
|
+
provider,
|
|
3562
|
+
user,
|
|
3563
|
+
providerId,
|
|
3564
|
+
providerSessionId: resolvedId,
|
|
3565
|
+
chatId,
|
|
3566
|
+
cwd: candidate.cwd ?? this.options.defaults?.defaultWorkspace ?? defaultWorkspaceDir(),
|
|
3567
|
+
recoverySource: "manual_attach",
|
|
3568
|
+
resumeTitle: candidate.resumeTitle
|
|
3569
|
+
});
|
|
3570
|
+
const head = `\u5DF2\u6062\u590D\u4F1A\u8BDD ${attached.id} \xB7 ${attached.providerId}`;
|
|
3571
|
+
const text = cwdUnresolved ? `${head}
|
|
3572
|
+
\u26A0\uFE0F \u65E0\u6CD5\u786E\u5B9A\u8BE5\u4F1A\u8BDD\u7684\u539F\u59CB\u76EE\u5F55\uFF0C\u5DF2\u4F7F\u7528\u9ED8\u8BA4\u76EE\u5F55 ${attached.cwd}\uFF0C\u5982\u9700\u5207\u6362\u8BF7\u7528 /new <\u76EE\u5F55> \u5F00\u65B0\u4F1A\u8BDD` : previousCwd && previousCwd !== attached.cwd ? `${head}
|
|
3573
|
+
\u5DF2\u5207\u6362\u5DE5\u4F5C\u76EE\u5F55\u5230 ${attached.cwd}` : `${head} \xB7 ${attached.cwd}`;
|
|
3574
|
+
await this.sendToChat({ chatId, kind: "status", text });
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
formatSessionLine(candidate) {
|
|
3579
|
+
const title = candidate.resumeTitle ?? candidate.title ?? candidate.id;
|
|
3580
|
+
const cwd = candidate.cwd ? ` \xB7 ${candidate.cwd}` : "";
|
|
3581
|
+
const when = candidate.lastActivityAt ? ` \xB7 ${formatRelativeTime(candidate.lastActivityAt)}` : "";
|
|
3582
|
+
return `${title}${cwd}${when}`;
|
|
3583
|
+
}
|
|
3584
|
+
async persistBridgeMetadata(session) {
|
|
3585
|
+
if (!session.providerSessionId || !session.resumeTitle) return;
|
|
3586
|
+
this.options.lastProviderSessions?.set(session.providerId, {
|
|
3587
|
+
providerSessionId: session.providerSessionId,
|
|
3588
|
+
cwd: session.cwd
|
|
3589
|
+
});
|
|
3590
|
+
if (session.providerId === "claude-code") {
|
|
3591
|
+
await ensureClaudeSessionBridgeMetadata({
|
|
3592
|
+
sessionId: session.providerSessionId,
|
|
3593
|
+
resumeTitle: session.resumeTitle,
|
|
3594
|
+
cwd: session.cwd
|
|
3595
|
+
});
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
};
|
|
3599
|
+
function summarizeResumeTitle(text) {
|
|
3600
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
3601
|
+
if (!normalized) return void 0;
|
|
3602
|
+
return normalized.length > 32 ? `${normalized.slice(0, 32).trimEnd()}\u2026` : normalized;
|
|
3603
|
+
}
|
|
3604
|
+
function resumeTitleFromContent(content) {
|
|
3605
|
+
const fromText = summarizeResumeTitle(content.text ?? "");
|
|
3606
|
+
if (fromText) return fromText;
|
|
3607
|
+
const kinds = new Set((content.attachments ?? []).map((att) => att.kind));
|
|
3608
|
+
const labels = [];
|
|
3609
|
+
if (kinds.has("image")) labels.push("\u56FE\u7247");
|
|
3610
|
+
if (kinds.has("video")) labels.push("\u89C6\u9891");
|
|
3611
|
+
if (kinds.has("file")) labels.push("\u6587\u4EF6");
|
|
3612
|
+
if (labels.length) return `\u5FAE\u4FE1\u4F1A\u8BDD \xB7 ${labels.join("\u3001")}`;
|
|
3613
|
+
if (content.quoted) return "\u5FAE\u4FE1\u4F1A\u8BDD \xB7 \u5F15\u7528";
|
|
3614
|
+
return "\u5FAE\u4FE1\u4F1A\u8BDD";
|
|
3615
|
+
}
|
|
3616
|
+
function isImmediateCommand(kind) {
|
|
3617
|
+
return kind === "cancel_generation" || kind === "help" || kind === "status" || kind === "list_sessions";
|
|
3618
|
+
}
|
|
3619
|
+
function formatRelativeTime(ts) {
|
|
3620
|
+
const diff = Date.now() - ts;
|
|
3621
|
+
if (diff < 6e4) return "\u521A\u521A";
|
|
3622
|
+
const minutes = Math.floor(diff / 6e4);
|
|
3623
|
+
if (minutes < 60) return `${minutes}\u5206\u949F\u524D`;
|
|
3624
|
+
const hours = Math.floor(minutes / 60);
|
|
3625
|
+
if (hours < 24) return `${hours}\u5C0F\u65F6\u524D`;
|
|
3626
|
+
const days = Math.floor(hours / 24);
|
|
3627
|
+
if (days < 30) return `${days}\u5929\u524D`;
|
|
3628
|
+
return `${Math.floor(days / 30)}\u4E2A\u6708\u524D`;
|
|
3629
|
+
}
|
|
3630
|
+
function formatAttachment(att) {
|
|
3631
|
+
const label = att.kind === "image" ? "\u56FE\u7247" : att.kind === "video" ? "\u89C6\u9891" : "\u6587\u4EF6";
|
|
3632
|
+
const name = att.fileName ? `${att.fileName} ` : "";
|
|
3633
|
+
if (att.failed) return `[${label}] ${name}[\u4E0B\u8F7D\u5931\u8D25${att.failReason ? `:${att.failReason}` : ""}]`;
|
|
3634
|
+
if (att.localPath) return `[${label}] ${name}@${att.localPath}`;
|
|
3635
|
+
return `[${label}] ${name}[\u65E0\u6CD5\u83B7\u53D6]`;
|
|
3636
|
+
}
|
|
3637
|
+
function composeInboundText(content) {
|
|
3638
|
+
const parts = [];
|
|
3639
|
+
if (content.text) parts.push(content.text);
|
|
3640
|
+
for (const att of content.attachments ?? []) parts.push(formatAttachment(att));
|
|
3641
|
+
if (content.quoted) {
|
|
3642
|
+
const quotedParts = [];
|
|
3643
|
+
if (content.quoted.text) quotedParts.push(content.quoted.text);
|
|
3644
|
+
for (const att of content.quoted.attachments ?? []) quotedParts.push(formatAttachment(att));
|
|
3645
|
+
if (quotedParts.length) parts.push(`[\u5F15\u7528] ${quotedParts.join(" ")}`);
|
|
3646
|
+
}
|
|
3647
|
+
return parts.join("\n");
|
|
3648
|
+
}
|
|
3649
|
+
|
|
3650
|
+
// src/storage/lastProviderSessionStore.ts
|
|
3651
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
|
|
3652
|
+
import { dirname as dirname7 } from "node:path";
|
|
3653
|
+
var LastProviderSessionStore = class {
|
|
3654
|
+
constructor(configPath) {
|
|
3655
|
+
this.configPath = configPath;
|
|
3656
|
+
}
|
|
3657
|
+
configPath;
|
|
3658
|
+
get(providerId) {
|
|
3659
|
+
return this.readState().bridge?.lastProviderSessions?.[providerId] ?? null;
|
|
3660
|
+
}
|
|
3661
|
+
set(providerId, input, updatedAt = Date.now()) {
|
|
3662
|
+
const record = { ...input, updatedAt };
|
|
3663
|
+
const state = this.readState();
|
|
3664
|
+
state.bridge = {
|
|
3665
|
+
...state.bridge ?? {},
|
|
3666
|
+
lastProviderSessions: {
|
|
3667
|
+
...state.bridge?.lastProviderSessions ?? {},
|
|
3668
|
+
[providerId]: record
|
|
3669
|
+
}
|
|
3670
|
+
};
|
|
3671
|
+
this.writeState(state);
|
|
3672
|
+
return record;
|
|
3673
|
+
}
|
|
3674
|
+
list() {
|
|
3675
|
+
return this.readState().bridge?.lastProviderSessions ?? {};
|
|
3676
|
+
}
|
|
3677
|
+
readState() {
|
|
3678
|
+
if (!existsSync6(this.configPath)) return {};
|
|
3679
|
+
const raw = JSON.parse(readFileSync4(this.configPath, "utf8"));
|
|
3680
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
3681
|
+
}
|
|
3682
|
+
writeState(state) {
|
|
3683
|
+
mkdirSync4(dirname7(this.configPath), { recursive: true });
|
|
3684
|
+
writeFileSync4(this.configPath, `${JSON.stringify(state, null, 2)}
|
|
3685
|
+
`, "utf8");
|
|
3686
|
+
}
|
|
3687
|
+
};
|
|
3688
|
+
|
|
3689
|
+
// src/storage/runtimeUserStore.ts
|
|
3690
|
+
import { mkdirSync as mkdirSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync7 } from "node:fs";
|
|
3691
|
+
import { dirname as dirname8 } from "node:path";
|
|
3692
|
+
import { nanoid as nanoid3 } from "nanoid";
|
|
3693
|
+
var RuntimeUserStore = class {
|
|
3694
|
+
constructor(configPath) {
|
|
3695
|
+
this.configPath = configPath;
|
|
3696
|
+
}
|
|
3697
|
+
configPath;
|
|
3698
|
+
setActiveUser(input) {
|
|
3699
|
+
const state = this.readState();
|
|
3700
|
+
const existing = state.bridge?.activeWeChatUser;
|
|
3701
|
+
const sameIdentity = existing && existing.platform === input.platform && existing.platformUserId === input.platformUserId;
|
|
3702
|
+
const record = {
|
|
3703
|
+
...sameIdentity ? existing : {},
|
|
3704
|
+
...input,
|
|
3705
|
+
id: sameIdentity ? existing.id : `user_${nanoid3(10)}`,
|
|
3706
|
+
createdAt: sameIdentity ? existing.createdAt : Date.now(),
|
|
3707
|
+
currentConversation: input.currentConversation ?? (sameIdentity ? existing.currentConversation : void 0),
|
|
3708
|
+
updatedAt: Date.now()
|
|
3709
|
+
};
|
|
3710
|
+
state.bridge = {
|
|
3711
|
+
...state.bridge ?? {},
|
|
3712
|
+
activeWeChatUser: record
|
|
3713
|
+
};
|
|
3714
|
+
this.writeState(state);
|
|
3715
|
+
return record;
|
|
3716
|
+
}
|
|
3717
|
+
isActiveUser(platform, platformUserId) {
|
|
3718
|
+
const activeUser = this.readState().bridge?.activeWeChatUser;
|
|
3719
|
+
if (!activeUser) return null;
|
|
3720
|
+
return activeUser.platform === platform && activeUser.platformUserId === platformUserId ? activeUser : null;
|
|
3721
|
+
}
|
|
3722
|
+
getActiveUser() {
|
|
3723
|
+
return this.readState().bridge?.activeWeChatUser ?? null;
|
|
3724
|
+
}
|
|
3725
|
+
updateActiveUser(platform, patch) {
|
|
3726
|
+
const state = this.readState();
|
|
3727
|
+
const activeUser = state.bridge?.activeWeChatUser;
|
|
3728
|
+
if (!activeUser || activeUser.platform !== platform) return;
|
|
3729
|
+
state.bridge = {
|
|
3730
|
+
...state.bridge ?? {},
|
|
3731
|
+
activeWeChatUser: {
|
|
3732
|
+
...activeUser,
|
|
3733
|
+
...patch.currentConversation !== void 0 ? { currentConversation: patch.currentConversation } : {},
|
|
3734
|
+
updatedAt: Date.now()
|
|
3735
|
+
}
|
|
3736
|
+
};
|
|
3737
|
+
this.writeState(state);
|
|
3738
|
+
}
|
|
3739
|
+
clearActiveUser(id) {
|
|
3740
|
+
const state = this.readState();
|
|
3741
|
+
const activeUser = state.bridge?.activeWeChatUser;
|
|
3742
|
+
if (!activeUser || activeUser.id !== id) return { ok: false, error: "user_not_found" };
|
|
3743
|
+
state.bridge = {
|
|
3744
|
+
...state.bridge ?? {},
|
|
3745
|
+
activeWeChatUser: void 0
|
|
3746
|
+
};
|
|
3747
|
+
this.writeState(state);
|
|
3748
|
+
return { ok: true };
|
|
3749
|
+
}
|
|
3750
|
+
readState() {
|
|
3751
|
+
if (!existsSync7(this.configPath)) return {};
|
|
3752
|
+
const raw = JSON.parse(readFileSync5(this.configPath, "utf8"));
|
|
3753
|
+
return raw && typeof raw === "object" ? raw : {};
|
|
3754
|
+
}
|
|
3755
|
+
writeState(state) {
|
|
3756
|
+
mkdirSync5(dirname8(this.configPath), { recursive: true });
|
|
3757
|
+
writeFileSync5(this.configPath, `${JSON.stringify(state, null, 2)}
|
|
3758
|
+
`, "utf8");
|
|
3759
|
+
}
|
|
3760
|
+
};
|
|
3761
|
+
|
|
3762
|
+
// src/daemon/events.ts
|
|
3763
|
+
var BridgeEventHub = class {
|
|
3764
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3765
|
+
emit(event) {
|
|
3766
|
+
for (const listener of this.listeners) {
|
|
3767
|
+
try {
|
|
3768
|
+
listener(event);
|
|
3769
|
+
} catch {
|
|
3770
|
+
}
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
subscribe(listener) {
|
|
3774
|
+
this.listeners.add(listener);
|
|
3775
|
+
return () => this.listeners.delete(listener);
|
|
3776
|
+
}
|
|
3777
|
+
};
|
|
3778
|
+
|
|
3779
|
+
// src/daemon/server.ts
|
|
3780
|
+
function createDaemonServer(options = {}) {
|
|
3781
|
+
const app = Fastify({ logger: true });
|
|
3782
|
+
const events = new BridgeEventHub();
|
|
3783
|
+
const bridgeDefaults = {
|
|
3784
|
+
defaultProvider: options.bridgeDefaults?.defaultProvider ?? "claude-code",
|
|
3785
|
+
defaultWorkspace: options.bridgeDefaults?.defaultWorkspace ?? process.cwd()
|
|
3786
|
+
};
|
|
3787
|
+
const configPath = options.configPath ?? process.env.BRIDGE_CONFIG ?? join8(mkdtempSync(join8(tmpdir2(), "claude-codex-wechat-")), "config.json");
|
|
3788
|
+
const conversation = new CurrentConversationStore(configPath, {
|
|
3789
|
+
defaultCwd: bridgeDefaults.defaultWorkspace,
|
|
3790
|
+
defaultProviderId: bridgeDefaults.defaultProvider
|
|
3791
|
+
});
|
|
3792
|
+
const providers = new ProviderRegistry({
|
|
3793
|
+
claudeCommand: options.providerCommands?.claude?.command,
|
|
3794
|
+
codexCommand: options.providerCommands?.codex?.command
|
|
3795
|
+
});
|
|
3796
|
+
const activeUserStore = options.activeUserStore ?? new RuntimeUserStore(configPath);
|
|
3797
|
+
const lastProviderSessions = new LastProviderSessionStore(configPath);
|
|
3798
|
+
const sessionBindingMatch = /* @__PURE__ */ new Map();
|
|
3799
|
+
const providerAdapters = options.providers ?? createDefaultProviders({
|
|
3800
|
+
claudeCommand: options.providerCommands?.claude?.command,
|
|
3801
|
+
codexCommand: options.providerCommands?.codex?.command
|
|
3802
|
+
});
|
|
3803
|
+
let currentConversation = conversation.getCurrent();
|
|
3804
|
+
if (currentConversation) {
|
|
3805
|
+
if (currentConversation.providerId === "claude-code" && currentConversation.providerSessionId && currentConversation.resumeTitle) {
|
|
3806
|
+
void ensureClaudeSessionBridgeMetadata({
|
|
3807
|
+
sessionId: currentConversation.providerSessionId,
|
|
3808
|
+
resumeTitle: currentConversation.resumeTitle,
|
|
3809
|
+
cwd: currentConversation.cwd
|
|
3810
|
+
});
|
|
3811
|
+
}
|
|
3812
|
+
const provider = providerAdapters.find((candidate) => candidate.id === currentConversation.providerId);
|
|
3813
|
+
void provider?.startSession({
|
|
3814
|
+
bridgeSessionId: currentConversation.id,
|
|
3815
|
+
cwd: currentConversation.cwd,
|
|
3816
|
+
options: currentConversation.providerSessionId ? { providerSessionId: currentConversation.providerSessionId } : void 0
|
|
3817
|
+
});
|
|
3818
|
+
}
|
|
3819
|
+
const weixinStateStore = configPath ? new FileWeixinStateStore(configPath) : void 0;
|
|
3820
|
+
const weixinMediaDir = configPath ? join8(dirname9(configPath), "media") : void 0;
|
|
3821
|
+
const managedWechatChannel = options.channel ? null : new ManagedWeixinDirectAdapter(options.wechat, weixinStateStore, weixinMediaDir);
|
|
3822
|
+
const channel = options.channel ?? managedWechatChannel;
|
|
3823
|
+
const weixinOutboundGate = managedWechatChannel && weixinStateStore ? new WeixinOutboundGate({
|
|
3824
|
+
store: weixinStateStore,
|
|
3825
|
+
send: async (chatId, msg) => {
|
|
3826
|
+
await managedWechatChannel.sendMessage({ chatId, kind: msg.kind, text: msg.text });
|
|
3827
|
+
}
|
|
3828
|
+
}) : void 0;
|
|
3829
|
+
const messageRouter = channel ? new MessageRouter({
|
|
3830
|
+
channel,
|
|
3831
|
+
providers: providerAdapters,
|
|
3832
|
+
conversation,
|
|
3833
|
+
outboundGate: weixinOutboundGate,
|
|
3834
|
+
resolveUser: (message) => activeUserStore.isActiveUser(PRIMARY_WEIXIN_PLATFORM, message.user.id),
|
|
3835
|
+
autoAuthorizeUser: (message) => {
|
|
3836
|
+
const existing = activeUserStore.isActiveUser(PRIMARY_WEIXIN_PLATFORM, message.user.id);
|
|
3837
|
+
if (existing) return existing;
|
|
3838
|
+
const created = activeUserStore.setActiveUser({
|
|
3839
|
+
platform: PRIMARY_WEIXIN_PLATFORM,
|
|
3840
|
+
platformUserId: message.user.id,
|
|
3841
|
+
displayName: message.user.displayName,
|
|
3842
|
+
role: "user"
|
|
3843
|
+
});
|
|
3844
|
+
events.emit({
|
|
3845
|
+
type: "channel.user-authorized",
|
|
3846
|
+
user: {
|
|
3847
|
+
id: created.id,
|
|
3848
|
+
platformUserId: created.platformUserId,
|
|
3849
|
+
platformType: "weixin",
|
|
3850
|
+
display_name: created.displayName,
|
|
3851
|
+
authorizedAt: created.createdAt,
|
|
3852
|
+
lastActive: created.updatedAt,
|
|
3853
|
+
provider: bridgeDefaults.defaultProvider,
|
|
3854
|
+
cwd: bridgeDefaults.defaultWorkspace
|
|
3855
|
+
}
|
|
3856
|
+
});
|
|
3857
|
+
return created;
|
|
3858
|
+
},
|
|
3859
|
+
autoAttachSession: async (message, user, opts) => {
|
|
3860
|
+
if (conversation.getCurrent()) return null;
|
|
3861
|
+
const provider = providerAdapters.find((candidate) => candidate.id === bridgeDefaults.defaultProvider);
|
|
3862
|
+
if (!provider?.attachSession || !provider.listRecoverableSessions) return null;
|
|
3863
|
+
const attached = await autoAttachProviderSessionForMessage({
|
|
3864
|
+
message,
|
|
3865
|
+
user,
|
|
3866
|
+
provider,
|
|
3867
|
+
conversationStore: conversation,
|
|
3868
|
+
lastProviderSessions,
|
|
3869
|
+
defaultProviderId: bridgeDefaults.defaultProvider,
|
|
3870
|
+
defaultCwd: bridgeDefaults.defaultWorkspace,
|
|
3871
|
+
...opts?.shouldCommit ? { shouldCommit: opts.shouldCommit } : {}
|
|
3872
|
+
});
|
|
3873
|
+
if (attached) {
|
|
3874
|
+
sessionBindingMatch.set(attached.session.id, attached.matchedBinding);
|
|
3875
|
+
return attached.session;
|
|
3876
|
+
}
|
|
3877
|
+
return null;
|
|
3878
|
+
},
|
|
3879
|
+
lastProviderSessions,
|
|
3880
|
+
events,
|
|
3881
|
+
defaults: bridgeDefaults
|
|
3882
|
+
}) : void 0;
|
|
3883
|
+
if (channel && messageRouter) {
|
|
3884
|
+
channel.onMessage(async (message) => {
|
|
3885
|
+
await messageRouter.handleMessage(message);
|
|
3886
|
+
});
|
|
3887
|
+
}
|
|
3888
|
+
registerChannelAdminRoutes({
|
|
3889
|
+
app,
|
|
3890
|
+
users: activeUserStore,
|
|
3891
|
+
...weixinStateStore ? { weixinStateStore } : {},
|
|
3892
|
+
...channel ? { channel } : {},
|
|
3893
|
+
lastProviderSessions,
|
|
3894
|
+
conversation,
|
|
3895
|
+
defaults: bridgeDefaults,
|
|
3896
|
+
providers: providerAdapters,
|
|
3897
|
+
getSessionBindingMatch: (sessionId) => sessionBindingMatch.get(sessionId) === true,
|
|
3898
|
+
wechat: options.wechat,
|
|
3899
|
+
events,
|
|
3900
|
+
configPath,
|
|
3901
|
+
onWechatConfigChanged: managedWechatChannel ? async (next) => {
|
|
3902
|
+
await managedWechatChannel.configure(next);
|
|
3903
|
+
} : void 0
|
|
3904
|
+
});
|
|
3905
|
+
registerSettingsRoutes({
|
|
3906
|
+
app,
|
|
3907
|
+
defaults: bridgeDefaults,
|
|
3908
|
+
configPath
|
|
3909
|
+
});
|
|
3910
|
+
app.get("/api/status", async () => ({
|
|
3911
|
+
ok: true,
|
|
3912
|
+
sessions: conversation.getCurrent() ? [conversation.getCurrent()] : []
|
|
3913
|
+
}));
|
|
3914
|
+
app.get("/api/providers/status", async () => providers.getStatus());
|
|
3915
|
+
app.post("/api/bridge-events", async (request, reply) => {
|
|
3916
|
+
reply.raw.writeHead(200, {
|
|
3917
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
3918
|
+
"cache-control": "no-cache",
|
|
3919
|
+
connection: "keep-alive"
|
|
3920
|
+
});
|
|
3921
|
+
const write = (event) => {
|
|
3922
|
+
try {
|
|
3923
|
+
reply.raw.write(`data: ${JSON.stringify(event)}
|
|
3924
|
+
|
|
3925
|
+
`);
|
|
3926
|
+
} catch {
|
|
3927
|
+
}
|
|
3928
|
+
};
|
|
3929
|
+
write({ type: "connected" });
|
|
3930
|
+
const unsubscribe = events.subscribe(write);
|
|
3931
|
+
const heartbeat = setInterval(() => write({ type: "ping" }), 15e3);
|
|
3932
|
+
const cleanup = () => {
|
|
3933
|
+
clearInterval(heartbeat);
|
|
3934
|
+
unsubscribe();
|
|
3935
|
+
};
|
|
3936
|
+
request.raw.on("close", cleanup);
|
|
3937
|
+
request.raw.on("error", cleanup);
|
|
3938
|
+
return reply.hijack();
|
|
3939
|
+
});
|
|
3940
|
+
app.addHook("onReady", async () => {
|
|
3941
|
+
await channel?.start({ background: true });
|
|
3942
|
+
});
|
|
3943
|
+
app.addHook("onClose", async () => {
|
|
3944
|
+
await channel?.stop();
|
|
3945
|
+
});
|
|
3946
|
+
return { app, conversation, sessions: conversation, events, activeUserStore };
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3949
|
+
// src/daemon/bootstrap.ts
|
|
3950
|
+
async function startDaemon(options) {
|
|
3951
|
+
const port = options.port ?? Number(process.env.BRIDGE_PORT ?? 8787);
|
|
3952
|
+
const host = options.host ?? "0.0.0.0";
|
|
3953
|
+
const configPath = options.configPath ?? process.env.BRIDGE_CONFIG ?? defaultConfigPath();
|
|
3954
|
+
const config = loadBridgeConfig(configPath);
|
|
3955
|
+
const providerCommands = await resolveProviderCommands(config.providers);
|
|
3956
|
+
await persistProviderCommandsToConfigFile({
|
|
3957
|
+
configPath,
|
|
3958
|
+
providers: providerCommands
|
|
3959
|
+
});
|
|
3960
|
+
const { app } = createDaemonServer({
|
|
3961
|
+
wechat: config.wechat,
|
|
3962
|
+
providerCommands,
|
|
3963
|
+
bridgeDefaults: {
|
|
3964
|
+
defaultProvider: config.bridge?.defaultProvider ?? "claude-code",
|
|
3965
|
+
defaultWorkspace: config.bridge?.defaultWorkspace ?? process.cwd()
|
|
3966
|
+
},
|
|
3967
|
+
configPath
|
|
3968
|
+
});
|
|
3969
|
+
await options.attachFrontend(app);
|
|
3970
|
+
await app.listen({ host, port });
|
|
3971
|
+
console.log("claude-codex-wechat listening:");
|
|
3972
|
+
console.log(` Local: http://127.0.0.1:${port}`);
|
|
3973
|
+
for (const ip of listLanIpv4Addresses()) {
|
|
3974
|
+
console.log(` Network: http://${ip}:${port}`);
|
|
3975
|
+
}
|
|
3976
|
+
console.log(`config path: ${configPath}`);
|
|
3977
|
+
return { app, port, host, configPath };
|
|
3978
|
+
}
|
|
3979
|
+
function listLanIpv4Addresses() {
|
|
3980
|
+
return Object.values(networkInterfaces()).flat().filter((iface) => Boolean(iface)).filter((iface) => iface.family === "IPv4" && !iface.internal).map((iface) => iface.address);
|
|
3981
|
+
}
|
|
3982
|
+
async function resolveProviderCommands(providers) {
|
|
3983
|
+
const claudeCommand = providers?.claude?.command ?? await findExecutable("claude");
|
|
3984
|
+
const codexCommand = providers?.codex?.command ?? await findExecutable("codex");
|
|
3985
|
+
if (!claudeCommand && !codexCommand) return void 0;
|
|
3986
|
+
return {
|
|
3987
|
+
...claudeCommand ? { claude: { command: claudeCommand } } : {},
|
|
3988
|
+
...codexCommand ? { codex: { command: codexCommand } } : {}
|
|
3989
|
+
};
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
// src/daemon/staticFrontend.ts
|
|
3993
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
3994
|
+
import { join as join9 } from "node:path";
|
|
3995
|
+
import fastifyStatic from "@fastify/static";
|
|
3996
|
+
function attachStaticFrontend(webRoot2) {
|
|
3997
|
+
return async (app) => {
|
|
3998
|
+
await app.register(fastifyStatic, {
|
|
3999
|
+
root: webRoot2,
|
|
4000
|
+
wildcard: false
|
|
4001
|
+
});
|
|
4002
|
+
const indexHtml = readFileSync6(join9(webRoot2, "index.html"), "utf8");
|
|
4003
|
+
app.setNotFoundHandler((request, reply) => {
|
|
4004
|
+
if (request.url.startsWith("/api")) {
|
|
4005
|
+
reply.code(404).send({ error: "not_found" });
|
|
4006
|
+
return;
|
|
4007
|
+
}
|
|
4008
|
+
reply.type("text/html").send(indexHtml);
|
|
4009
|
+
});
|
|
4010
|
+
};
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
// src/cli.ts
|
|
4014
|
+
var here = dirname10(fileURLToPath(import.meta.url));
|
|
4015
|
+
var webRoot = join10(here, "..", "web");
|
|
4016
|
+
async function main() {
|
|
4017
|
+
const command = process.argv[2];
|
|
4018
|
+
switch (command) {
|
|
4019
|
+
case void 0:
|
|
4020
|
+
case "start":
|
|
4021
|
+
await cmdStart();
|
|
4022
|
+
return;
|
|
4023
|
+
case "init":
|
|
4024
|
+
cmdInit();
|
|
4025
|
+
return;
|
|
4026
|
+
case "doctor":
|
|
4027
|
+
await cmdDoctor();
|
|
4028
|
+
return;
|
|
4029
|
+
case "print-config":
|
|
4030
|
+
cmdPrintConfig();
|
|
4031
|
+
return;
|
|
4032
|
+
case "help":
|
|
4033
|
+
case "--help":
|
|
4034
|
+
case "-h":
|
|
4035
|
+
printUsage();
|
|
4036
|
+
return;
|
|
4037
|
+
default:
|
|
4038
|
+
console.error(`\u672A\u77E5\u547D\u4EE4: ${command}
|
|
4039
|
+
`);
|
|
4040
|
+
printUsage();
|
|
4041
|
+
process.exitCode = 1;
|
|
4042
|
+
}
|
|
4043
|
+
}
|
|
4044
|
+
async function cmdStart() {
|
|
4045
|
+
if (!existsSync8(webRoot)) {
|
|
4046
|
+
console.error(`\u627E\u4E0D\u5230\u524D\u7AEF\u6784\u5EFA\u4EA7\u7269: ${webRoot}`);
|
|
4047
|
+
console.error("\u8BF7\u5148\u8FD0\u884C\u6784\u5EFA (pnpm build) \u540E\u518D\u542F\u52A8\uFF0C\u6216\u91CD\u65B0\u5B89\u88C5\u5B8C\u6574\u7684 npm \u5305\u3002");
|
|
4048
|
+
process.exitCode = 1;
|
|
4049
|
+
return;
|
|
4050
|
+
}
|
|
4051
|
+
await startDaemon({
|
|
4052
|
+
attachFrontend: attachStaticFrontend(webRoot)
|
|
4053
|
+
});
|
|
4054
|
+
}
|
|
4055
|
+
function cmdInit() {
|
|
4056
|
+
const configPath = process.env.BRIDGE_CONFIG ?? defaultConfigPath();
|
|
4057
|
+
const dir = dirname10(configPath);
|
|
4058
|
+
mkdirSync6(dir, { recursive: true });
|
|
4059
|
+
if (existsSync8(configPath)) {
|
|
4060
|
+
console.log(`\u914D\u7F6E\u5DF2\u5B58\u5728\uFF0C\u672A\u8986\u76D6: ${configPath}`);
|
|
4061
|
+
return;
|
|
4062
|
+
}
|
|
4063
|
+
const template = {
|
|
4064
|
+
wechat: {
|
|
4065
|
+
enabled: true,
|
|
4066
|
+
baseUrl: "https://ilinkai.weixin.qq.com",
|
|
4067
|
+
token: "replace-with-weixin-bot-token",
|
|
4068
|
+
accountId: "replace-with-weixin-account-id"
|
|
4069
|
+
},
|
|
4070
|
+
providers: {},
|
|
4071
|
+
bridge: {
|
|
4072
|
+
defaultProvider: "claude-code",
|
|
4073
|
+
defaultWorkspace: process.cwd()
|
|
4074
|
+
}
|
|
4075
|
+
};
|
|
4076
|
+
writeFileSync6(configPath, `${JSON.stringify(template, null, 2)}
|
|
4077
|
+
`, "utf8");
|
|
4078
|
+
console.log(`\u5DF2\u521B\u5EFA\u914D\u7F6E: ${configPath}`);
|
|
4079
|
+
console.log("\u8BF7\u586B\u5165\u5FAE\u4FE1 token / accountId \u540E\u8FD0\u884C: claude-codex-wechat start");
|
|
4080
|
+
}
|
|
4081
|
+
async function cmdDoctor() {
|
|
4082
|
+
const configPath = process.env.BRIDGE_CONFIG ?? defaultConfigPath();
|
|
4083
|
+
console.log("claude-codex-wechat doctor\n");
|
|
4084
|
+
report("\u914D\u7F6E\u6587\u4EF6", existsSync8(configPath) ? configPath : `\u7F3A\u5931 (${configPath})\uFF0C\u8FD0\u884C init \u521B\u5EFA`);
|
|
4085
|
+
report("\u524D\u7AEF\u4EA7\u7269", existsSync8(webRoot) ? webRoot : `\u7F3A\u5931 (${webRoot})`);
|
|
4086
|
+
const claude = await findExecutable("claude");
|
|
4087
|
+
report("claude \u53EF\u6267\u884C", claude ?? "\u672A\u627E\u5230\uFF0C\u9700\u5148\u5B89\u88C5\u5E76\u767B\u5F55 Claude Code");
|
|
4088
|
+
const codex = await findExecutable("codex");
|
|
4089
|
+
report("codex \u53EF\u6267\u884C", codex ?? "\u672A\u627E\u5230\uFF08\u4EC5\u4F7F\u7528 Claude \u65F6\u53EF\u5FFD\u7565\uFF09");
|
|
4090
|
+
if (existsSync8(configPath)) {
|
|
4091
|
+
const config = loadBridgeConfig(configPath);
|
|
4092
|
+
const wechat = config.wechat;
|
|
4093
|
+
report("\u5FAE\u4FE1\u542F\u7528", wechat?.enabled ? "\u662F" : "\u5426");
|
|
4094
|
+
report("\u5FAE\u4FE1 token", wechat?.token ? "\u5DF2\u914D\u7F6E" : "\u672A\u914D\u7F6E");
|
|
4095
|
+
report("\u5FAE\u4FE1 accountId", wechat?.accountId ? "\u5DF2\u914D\u7F6E" : "\u672A\u914D\u7F6E");
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
4098
|
+
function cmdPrintConfig() {
|
|
4099
|
+
const configPath = process.env.BRIDGE_CONFIG ?? defaultConfigPath();
|
|
4100
|
+
if (!existsSync8(configPath)) {
|
|
4101
|
+
console.error(`\u914D\u7F6E\u4E0D\u5B58\u5728: ${configPath}\uFF08\u8FD0\u884C init \u521B\u5EFA\uFF09`);
|
|
4102
|
+
process.exitCode = 1;
|
|
4103
|
+
return;
|
|
4104
|
+
}
|
|
4105
|
+
console.log(readFileSync7(configPath, "utf8"));
|
|
4106
|
+
}
|
|
4107
|
+
function report(label, value) {
|
|
4108
|
+
console.log(` ${label.padEnd(14)}: ${value}`);
|
|
4109
|
+
}
|
|
4110
|
+
function printUsage() {
|
|
4111
|
+
console.log(`claude-codex-wechat \u2014 \u672C\u5730 WeChat \u2194 Claude/Codex bridge daemon
|
|
4112
|
+
|
|
4113
|
+
\u7528\u6CD5:
|
|
4114
|
+
claude-codex-wechat <command>
|
|
4115
|
+
|
|
4116
|
+
\u547D\u4EE4:
|
|
4117
|
+
start \u542F\u52A8 daemon\uFF08\u9ED8\u8BA4\u547D\u4EE4\uFF0C\u524D\u53F0\u8FD0\u884C\uFF09
|
|
4118
|
+
init \u5728 ~/.claude-codex-wechat/ \u521B\u5EFA\u9ED8\u8BA4\u914D\u7F6E
|
|
4119
|
+
doctor \u68C0\u67E5\u914D\u7F6E\u3001\u524D\u7AEF\u4EA7\u7269\u4E0E claude/codex \u53EF\u6267\u884C\u6587\u4EF6
|
|
4120
|
+
print-config \u6253\u5370\u5F53\u524D\u914D\u7F6E\u6587\u4EF6\u5185\u5BB9
|
|
4121
|
+
help \u663E\u793A\u672C\u5E2E\u52A9
|
|
4122
|
+
|
|
4123
|
+
\u73AF\u5883\u53D8\u91CF:
|
|
4124
|
+
BRIDGE_PORT \u76D1\u542C\u7AEF\u53E3\uFF08\u9ED8\u8BA4 8787\uFF09
|
|
4125
|
+
BRIDGE_CONFIG \u914D\u7F6E\u6587\u4EF6\u8DEF\u5F84\uFF08\u9ED8\u8BA4 ~/.claude-codex-wechat/config.json\uFF09
|
|
4126
|
+
|
|
4127
|
+
\u5E38\u9A7B\u8FD0\u884C\u8BF7\u7528\u8FDB\u7A0B\u7BA1\u7406\u5668\u6258\u7BA1\uFF0C\u4F8B\u5982:
|
|
4128
|
+
pm2 start claude-codex-wechat -- start`);
|
|
4129
|
+
}
|
|
4130
|
+
await main();
|