weixin-agent-sdk 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/dist/index.mjs ADDED
@@ -0,0 +1,2088 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import crypto, { createCipheriv, createDecipheriv, randomUUID } from "node:crypto";
5
+ import { fileURLToPath } from "node:url";
6
+ import fs$1 from "node:fs/promises";
7
+ //#region src/storage/state-dir.ts
8
+ /** Resolve the OpenClaw state directory (mirrors core logic in src/infra). */
9
+ function resolveStateDir() {
10
+ return process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw");
11
+ }
12
+ const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
13
+ /** Normalize an account ID to a filesystem-safe string. */
14
+ function normalizeAccountId(raw) {
15
+ return raw.trim().toLowerCase().replace(/[@.]/g, "-");
16
+ }
17
+ /**
18
+ * Pattern-based reverse of normalizeWeixinAccountId for known weixin ID suffixes.
19
+ * Used only as a compatibility fallback when loading accounts / sync bufs stored
20
+ * under the old raw ID.
21
+ * e.g. "b0f5860fdecb-im-bot" → "b0f5860fdecb@im.bot"
22
+ */
23
+ function deriveRawAccountId(normalizedId) {
24
+ if (normalizedId.endsWith("-im-bot")) return `${normalizedId.slice(0, -7)}@im.bot`;
25
+ if (normalizedId.endsWith("-im-wechat")) return `${normalizedId.slice(0, -10)}@im.wechat`;
26
+ }
27
+ function resolveWeixinStateDir() {
28
+ return path.join(resolveStateDir(), "openclaw-weixin");
29
+ }
30
+ function resolveAccountIndexPath() {
31
+ return path.join(resolveWeixinStateDir(), "accounts.json");
32
+ }
33
+ /** Returns all accountIds registered via QR login. */
34
+ function listIndexedWeixinAccountIds() {
35
+ const filePath = resolveAccountIndexPath();
36
+ try {
37
+ if (!fs.existsSync(filePath)) return [];
38
+ const raw = fs.readFileSync(filePath, "utf-8");
39
+ const parsed = JSON.parse(raw);
40
+ if (!Array.isArray(parsed)) return [];
41
+ return parsed.filter((id) => typeof id === "string" && id.trim() !== "");
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
46
+ /** Register accountId as the sole account in the persistent index. */
47
+ function registerWeixinAccountId(accountId) {
48
+ const dir = resolveWeixinStateDir();
49
+ fs.mkdirSync(dir, { recursive: true });
50
+ fs.writeFileSync(resolveAccountIndexPath(), JSON.stringify([accountId], null, 2), "utf-8");
51
+ }
52
+ function resolveAccountsDir$1() {
53
+ return path.join(resolveWeixinStateDir(), "accounts");
54
+ }
55
+ function resolveAccountPath(accountId) {
56
+ return path.join(resolveAccountsDir$1(), `${accountId}.json`);
57
+ }
58
+ /**
59
+ * Legacy single-file token: `credentials/openclaw-weixin/credentials.json` (pre per-account files).
60
+ */
61
+ function loadLegacyToken() {
62
+ const legacyPath = path.join(resolveStateDir(), "credentials", "openclaw-weixin", "credentials.json");
63
+ try {
64
+ if (!fs.existsSync(legacyPath)) return void 0;
65
+ const raw = fs.readFileSync(legacyPath, "utf-8");
66
+ const parsed = JSON.parse(raw);
67
+ return typeof parsed.token === "string" ? parsed.token : void 0;
68
+ } catch {
69
+ return;
70
+ }
71
+ }
72
+ function readAccountFile(filePath) {
73
+ try {
74
+ if (fs.existsSync(filePath)) return JSON.parse(fs.readFileSync(filePath, "utf-8"));
75
+ } catch {}
76
+ return null;
77
+ }
78
+ /** Load account data by ID, with compatibility fallbacks. */
79
+ function loadWeixinAccount(accountId) {
80
+ const primary = readAccountFile(resolveAccountPath(accountId));
81
+ if (primary) return primary;
82
+ const rawId = deriveRawAccountId(accountId);
83
+ if (rawId) {
84
+ const compat = readAccountFile(resolveAccountPath(rawId));
85
+ if (compat) return compat;
86
+ }
87
+ const token = loadLegacyToken();
88
+ if (token) return { token };
89
+ return null;
90
+ }
91
+ /**
92
+ * Persist account data after QR login (merges into existing file).
93
+ * - token: overwritten when provided.
94
+ * - baseUrl: stored when non-empty; resolveWeixinAccount falls back to DEFAULT_BASE_URL.
95
+ * - userId: set when `update.userId` is provided; omitted from file when cleared to empty.
96
+ */
97
+ function saveWeixinAccount(accountId, update) {
98
+ const dir = resolveAccountsDir$1();
99
+ fs.mkdirSync(dir, { recursive: true });
100
+ const existing = loadWeixinAccount(accountId) ?? {};
101
+ const token = update.token?.trim() || existing.token;
102
+ const baseUrl = update.baseUrl?.trim() || existing.baseUrl;
103
+ const userId = update.userId !== void 0 ? update.userId.trim() || void 0 : existing.userId?.trim() || void 0;
104
+ const data = {
105
+ ...token ? {
106
+ token,
107
+ savedAt: (/* @__PURE__ */ new Date()).toISOString()
108
+ } : {},
109
+ ...baseUrl ? { baseUrl } : {},
110
+ ...userId ? { userId } : {}
111
+ };
112
+ const filePath = resolveAccountPath(accountId);
113
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8");
114
+ try {
115
+ fs.chmodSync(filePath, 384);
116
+ } catch {}
117
+ }
118
+ /**
119
+ * Resolve the openclaw.json config file path.
120
+ * Checks OPENCLAW_CONFIG env var, then state dir.
121
+ */
122
+ function resolveConfigPath() {
123
+ const envPath = process.env.OPENCLAW_CONFIG?.trim();
124
+ if (envPath) return envPath;
125
+ return path.join(resolveStateDir(), "openclaw.json");
126
+ }
127
+ /**
128
+ * Read `routeTag` from openclaw.json (for callers without an `OpenClawConfig` object).
129
+ * Checks per-account `channels.<id>.accounts[accountId].routeTag` first, then section-level
130
+ * `channels.<id>.routeTag`. Matches `feat_weixin_extension` behavior; channel key is `"openclaw-weixin"`.
131
+ */
132
+ function loadConfigRouteTag(accountId) {
133
+ try {
134
+ const configPath = resolveConfigPath();
135
+ if (!fs.existsSync(configPath)) return void 0;
136
+ const raw = fs.readFileSync(configPath, "utf-8");
137
+ const section = JSON.parse(raw).channels?.["openclaw-weixin"];
138
+ if (!section) return void 0;
139
+ if (accountId) {
140
+ const tag = section.accounts?.[accountId]?.routeTag;
141
+ if (typeof tag === "number") return String(tag);
142
+ if (typeof tag === "string" && tag.trim()) return tag.trim();
143
+ }
144
+ if (typeof section.routeTag === "number") return String(section.routeTag);
145
+ return typeof section.routeTag === "string" && section.routeTag.trim() ? section.routeTag.trim() : void 0;
146
+ } catch {
147
+ return;
148
+ }
149
+ }
150
+ /** List accountIds from the index file (written at QR login). */
151
+ function listWeixinAccountIds() {
152
+ return listIndexedWeixinAccountIds();
153
+ }
154
+ /** Resolve a weixin account by ID, reading stored credentials. */
155
+ function resolveWeixinAccount(accountId) {
156
+ const raw = accountId?.trim();
157
+ if (!raw) throw new Error("weixin: accountId is required (no default account)");
158
+ const id = normalizeAccountId(raw);
159
+ const accountData = loadWeixinAccount(id);
160
+ const token = accountData?.token?.trim() || void 0;
161
+ return {
162
+ accountId: id,
163
+ baseUrl: accountData?.baseUrl?.trim() || "https://ilinkai.weixin.qq.com",
164
+ cdnBaseUrl: CDN_BASE_URL,
165
+ token,
166
+ enabled: true,
167
+ configured: Boolean(token)
168
+ };
169
+ }
170
+ //#endregion
171
+ //#region src/util/logger.ts
172
+ /**
173
+ * Plugin logger — writes JSON lines to the main openclaw log file:
174
+ * /tmp/openclaw/openclaw-YYYY-MM-DD.log
175
+ * Same file and format used by all other channels.
176
+ */
177
+ const MAIN_LOG_DIR = path.join("/tmp", "openclaw");
178
+ const SUBSYSTEM = "gateway/channels/openclaw-weixin";
179
+ const RUNTIME = "node";
180
+ const RUNTIME_VERSION = process.versions.node;
181
+ const HOSTNAME = os.hostname() || "unknown";
182
+ const PARENT_NAMES = ["openclaw"];
183
+ /** tslog-compatible level IDs (higher = more severe). */
184
+ const LEVEL_IDS = {
185
+ TRACE: 1,
186
+ DEBUG: 2,
187
+ INFO: 3,
188
+ WARN: 4,
189
+ ERROR: 5,
190
+ FATAL: 6
191
+ };
192
+ const DEFAULT_LOG_LEVEL = "INFO";
193
+ function resolveMinLevel() {
194
+ const env = process.env.OPENCLAW_LOG_LEVEL?.toUpperCase();
195
+ if (env && env in LEVEL_IDS) return LEVEL_IDS[env];
196
+ return LEVEL_IDS[DEFAULT_LOG_LEVEL];
197
+ }
198
+ let minLevelId = resolveMinLevel();
199
+ /** Shift a Date into local time so toISOString() renders local clock digits. */
200
+ function toLocalISO(now) {
201
+ const offsetMs = -now.getTimezoneOffset() * 6e4;
202
+ const sign = offsetMs >= 0 ? "+" : "-";
203
+ const abs = Math.abs(now.getTimezoneOffset());
204
+ const offStr = `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
205
+ return new Date(now.getTime() + offsetMs).toISOString().replace("Z", offStr);
206
+ }
207
+ function localDateKey(now) {
208
+ return toLocalISO(now).slice(0, 10);
209
+ }
210
+ function resolveMainLogPath() {
211
+ const dateKey = localDateKey(/* @__PURE__ */ new Date());
212
+ return path.join(MAIN_LOG_DIR, `openclaw-${dateKey}.log`);
213
+ }
214
+ let logDirEnsured = false;
215
+ function buildLoggerName(accountId) {
216
+ return accountId ? `${SUBSYSTEM}/${accountId}` : SUBSYSTEM;
217
+ }
218
+ function writeLog(level, message, accountId) {
219
+ if ((LEVEL_IDS[level] ?? LEVEL_IDS.INFO) < minLevelId) return;
220
+ const now = /* @__PURE__ */ new Date();
221
+ const loggerName = buildLoggerName(accountId);
222
+ const prefixedMessage = accountId ? `[${accountId}] ${message}` : message;
223
+ const entry = JSON.stringify({
224
+ "0": loggerName,
225
+ "1": prefixedMessage,
226
+ _meta: {
227
+ runtime: RUNTIME,
228
+ runtimeVersion: RUNTIME_VERSION,
229
+ hostname: HOSTNAME,
230
+ name: loggerName,
231
+ parentNames: PARENT_NAMES,
232
+ date: now.toISOString(),
233
+ logLevelId: LEVEL_IDS[level] ?? LEVEL_IDS.INFO,
234
+ logLevelName: level
235
+ },
236
+ time: toLocalISO(now)
237
+ });
238
+ try {
239
+ if (!logDirEnsured) {
240
+ fs.mkdirSync(MAIN_LOG_DIR, { recursive: true });
241
+ logDirEnsured = true;
242
+ }
243
+ fs.appendFileSync(resolveMainLogPath(), `${entry}\n`, "utf-8");
244
+ } catch {}
245
+ }
246
+ /** Creates a logger instance, optionally bound to a specific account. */
247
+ function createLogger(accountId) {
248
+ return {
249
+ info(message) {
250
+ writeLog("INFO", message, accountId);
251
+ },
252
+ debug(message) {
253
+ writeLog("DEBUG", message, accountId);
254
+ },
255
+ warn(message) {
256
+ writeLog("WARN", message, accountId);
257
+ },
258
+ error(message) {
259
+ writeLog("ERROR", message, accountId);
260
+ },
261
+ withAccount(id) {
262
+ return createLogger(id);
263
+ },
264
+ getLogFilePath() {
265
+ return resolveMainLogPath();
266
+ },
267
+ close() {}
268
+ };
269
+ }
270
+ const logger = createLogger();
271
+ //#endregion
272
+ //#region src/util/redact.ts
273
+ const DEFAULT_BODY_MAX_LEN = 200;
274
+ const DEFAULT_TOKEN_PREFIX_LEN = 6;
275
+ /**
276
+ * Truncate a string, appending a length indicator when trimmed.
277
+ * Returns `""` for empty/undefined input.
278
+ */
279
+ function truncate(s, max) {
280
+ if (!s) return "";
281
+ if (s.length <= max) return s;
282
+ return `${s.slice(0, max)}…(len=${s.length})`;
283
+ }
284
+ /**
285
+ * Redact a token/secret: show only the first few chars + total length.
286
+ * Returns `"(none)"` when absent.
287
+ */
288
+ function redactToken(token, prefixLen = DEFAULT_TOKEN_PREFIX_LEN) {
289
+ if (!token) return "(none)";
290
+ if (token.length <= prefixLen) return `****(len=${token.length})`;
291
+ return `${token.slice(0, prefixLen)}…(len=${token.length})`;
292
+ }
293
+ /**
294
+ * Truncate a JSON body string to `maxLen` chars for safe logging.
295
+ * Appends original length so the reader knows how much was dropped.
296
+ */
297
+ function redactBody(body, maxLen = DEFAULT_BODY_MAX_LEN) {
298
+ if (!body) return "(empty)";
299
+ if (body.length <= maxLen) return body;
300
+ return `${body.slice(0, maxLen)}…(truncated, totalLen=${body.length})`;
301
+ }
302
+ /**
303
+ * Strip query string (which often contains signatures/tokens) from a URL,
304
+ * keeping only origin + pathname.
305
+ */
306
+ function redactUrl(rawUrl) {
307
+ try {
308
+ const u = new URL(rawUrl);
309
+ const base = `${u.origin}${u.pathname}`;
310
+ return u.search ? `${base}?<redacted>` : base;
311
+ } catch {
312
+ return truncate(rawUrl, 80);
313
+ }
314
+ }
315
+ //#endregion
316
+ //#region src/auth/login-qr.ts
317
+ const ACTIVE_LOGIN_TTL_MS = 5 * 6e4;
318
+ /** Client-side timeout for the long-poll get_qrcode_status request. */
319
+ const QR_LONG_POLL_TIMEOUT_MS = 35e3;
320
+ const activeLogins = /* @__PURE__ */ new Map();
321
+ function isLoginFresh(login) {
322
+ return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
323
+ }
324
+ /** Remove all expired entries from the activeLogins map to prevent memory leaks. */
325
+ function purgeExpiredLogins() {
326
+ for (const [id, login] of activeLogins) if (!isLoginFresh(login)) activeLogins.delete(id);
327
+ }
328
+ async function fetchQRCode(apiBaseUrl, botType) {
329
+ const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
330
+ const url = new URL(`ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, base);
331
+ logger.info(`Fetching QR code from: ${url.toString()}`);
332
+ const headers = {};
333
+ const routeTag = loadConfigRouteTag();
334
+ if (routeTag) headers.SKRouteTag = routeTag;
335
+ const response = await fetch(url.toString(), { headers });
336
+ if (!response.ok) {
337
+ const body = await response.text().catch(() => "(unreadable)");
338
+ logger.error(`QR code fetch failed: ${response.status} ${response.statusText} body=${body}`);
339
+ throw new Error(`Failed to fetch QR code: ${response.status} ${response.statusText}`);
340
+ }
341
+ return await response.json();
342
+ }
343
+ async function pollQRStatus(apiBaseUrl, qrcode) {
344
+ const base = apiBaseUrl.endsWith("/") ? apiBaseUrl : `${apiBaseUrl}/`;
345
+ const url = new URL(`ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, base);
346
+ logger.debug(`Long-poll QR status from: ${url.toString()}`);
347
+ const headers = { "iLink-App-ClientVersion": "1" };
348
+ const routeTag = loadConfigRouteTag();
349
+ if (routeTag) headers.SKRouteTag = routeTag;
350
+ const controller = new AbortController();
351
+ const timer = setTimeout(() => controller.abort(), QR_LONG_POLL_TIMEOUT_MS);
352
+ try {
353
+ const response = await fetch(url.toString(), {
354
+ headers,
355
+ signal: controller.signal
356
+ });
357
+ clearTimeout(timer);
358
+ logger.debug(`pollQRStatus: HTTP ${response.status}, reading body...`);
359
+ const rawText = await response.text();
360
+ logger.debug(`pollQRStatus: body=${rawText.substring(0, 200)}`);
361
+ if (!response.ok) {
362
+ logger.error(`QR status poll failed: ${response.status} ${response.statusText} body=${rawText}`);
363
+ throw new Error(`Failed to poll QR status: ${response.status} ${response.statusText}`);
364
+ }
365
+ return JSON.parse(rawText);
366
+ } catch (err) {
367
+ clearTimeout(timer);
368
+ if (err instanceof Error && err.name === "AbortError") {
369
+ logger.debug(`pollQRStatus: client-side timeout after ${QR_LONG_POLL_TIMEOUT_MS}ms, returning wait`);
370
+ return { status: "wait" };
371
+ }
372
+ throw err;
373
+ }
374
+ }
375
+ async function startWeixinLoginWithQr(opts) {
376
+ const sessionKey = opts.accountId || randomUUID();
377
+ purgeExpiredLogins();
378
+ const existing = activeLogins.get(sessionKey);
379
+ if (!opts.force && existing && isLoginFresh(existing) && existing.qrcodeUrl) return {
380
+ qrcodeUrl: existing.qrcodeUrl,
381
+ message: "二维码已就绪,请使用微信扫描。",
382
+ sessionKey
383
+ };
384
+ try {
385
+ const botType = opts.botType || "3";
386
+ logger.info(`Starting Weixin login with bot_type=${botType}`);
387
+ if (!opts.apiBaseUrl) return {
388
+ message: "No baseUrl configured. Add channels.openclaw-weixin.baseUrl to your config before logging in.",
389
+ sessionKey
390
+ };
391
+ const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
392
+ logger.info(`QR code received, qrcode=${redactToken(qrResponse.qrcode)} imgContentLen=${qrResponse.qrcode_img_content?.length ?? 0}`);
393
+ logger.info(`二维码链接: ${qrResponse.qrcode_img_content}`);
394
+ const login = {
395
+ sessionKey,
396
+ id: randomUUID(),
397
+ qrcode: qrResponse.qrcode,
398
+ qrcodeUrl: qrResponse.qrcode_img_content,
399
+ startedAt: Date.now()
400
+ };
401
+ activeLogins.set(sessionKey, login);
402
+ return {
403
+ qrcodeUrl: qrResponse.qrcode_img_content,
404
+ message: "使用微信扫描以下二维码,以完成连接。",
405
+ sessionKey
406
+ };
407
+ } catch (err) {
408
+ logger.error(`Failed to start Weixin login: ${String(err)}`);
409
+ return {
410
+ message: `Failed to start login: ${String(err)}`,
411
+ sessionKey
412
+ };
413
+ }
414
+ }
415
+ const MAX_QR_REFRESH_COUNT = 3;
416
+ async function waitForWeixinLogin(opts) {
417
+ let activeLogin = activeLogins.get(opts.sessionKey);
418
+ if (!activeLogin) {
419
+ logger.warn(`waitForWeixinLogin: no active login sessionKey=${opts.sessionKey}`);
420
+ return {
421
+ connected: false,
422
+ message: "当前没有进行中的登录,请先发起登录。"
423
+ };
424
+ }
425
+ if (!isLoginFresh(activeLogin)) {
426
+ logger.warn(`waitForWeixinLogin: login QR expired sessionKey=${opts.sessionKey}`);
427
+ activeLogins.delete(opts.sessionKey);
428
+ return {
429
+ connected: false,
430
+ message: "二维码已过期,请重新生成。"
431
+ };
432
+ }
433
+ const timeoutMs = Math.max(opts.timeoutMs ?? 48e4, 1e3);
434
+ const deadline = Date.now() + timeoutMs;
435
+ let scannedPrinted = false;
436
+ let qrRefreshCount = 1;
437
+ logger.info("Starting to poll QR code status...");
438
+ while (Date.now() < deadline) {
439
+ try {
440
+ const statusResponse = await pollQRStatus(opts.apiBaseUrl, activeLogin.qrcode);
441
+ logger.debug(`pollQRStatus: status=${statusResponse.status} hasBotToken=${Boolean(statusResponse.bot_token)} hasBotId=${Boolean(statusResponse.ilink_bot_id)}`);
442
+ activeLogin.status = statusResponse.status;
443
+ switch (statusResponse.status) {
444
+ case "wait":
445
+ if (opts.verbose) process.stdout.write(".");
446
+ break;
447
+ case "scaned":
448
+ if (!scannedPrinted) {
449
+ process.stdout.write("\n👀 已扫码,在微信继续操作...\n");
450
+ scannedPrinted = true;
451
+ }
452
+ break;
453
+ case "expired":
454
+ qrRefreshCount++;
455
+ if (qrRefreshCount > MAX_QR_REFRESH_COUNT) {
456
+ logger.warn(`waitForWeixinLogin: QR expired ${MAX_QR_REFRESH_COUNT} times, giving up sessionKey=${opts.sessionKey}`);
457
+ activeLogins.delete(opts.sessionKey);
458
+ return {
459
+ connected: false,
460
+ message: "登录超时:二维码多次过期,请重新开始登录流程。"
461
+ };
462
+ }
463
+ process.stdout.write(`\n⏳ 二维码已过期,正在刷新...(${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})\n`);
464
+ logger.info(`waitForWeixinLogin: QR expired, refreshing (${qrRefreshCount}/${MAX_QR_REFRESH_COUNT})`);
465
+ try {
466
+ const botType = opts.botType || "3";
467
+ const qrResponse = await fetchQRCode(opts.apiBaseUrl, botType);
468
+ activeLogin.qrcode = qrResponse.qrcode;
469
+ activeLogin.qrcodeUrl = qrResponse.qrcode_img_content;
470
+ activeLogin.startedAt = Date.now();
471
+ scannedPrinted = false;
472
+ logger.info(`waitForWeixinLogin: new QR code obtained qrcode=${redactToken(qrResponse.qrcode)}`);
473
+ process.stdout.write(`🔄 新二维码已生成,请重新扫描\n\n`);
474
+ try {
475
+ (await import("qrcode-terminal")).default.generate(qrResponse.qrcode_img_content, { small: true });
476
+ } catch {
477
+ process.stdout.write(`QR Code URL: ${qrResponse.qrcode_img_content}\n`);
478
+ }
479
+ } catch (refreshErr) {
480
+ logger.error(`waitForWeixinLogin: failed to refresh QR code: ${String(refreshErr)}`);
481
+ activeLogins.delete(opts.sessionKey);
482
+ return {
483
+ connected: false,
484
+ message: `刷新二维码失败: ${String(refreshErr)}`
485
+ };
486
+ }
487
+ break;
488
+ case "confirmed":
489
+ if (!statusResponse.ilink_bot_id) {
490
+ activeLogins.delete(opts.sessionKey);
491
+ logger.error("Login confirmed but ilink_bot_id missing from response");
492
+ return {
493
+ connected: false,
494
+ message: "登录失败:服务器未返回 ilink_bot_id。"
495
+ };
496
+ }
497
+ activeLogin.botToken = statusResponse.bot_token;
498
+ activeLogins.delete(opts.sessionKey);
499
+ logger.info(`✅ Login confirmed! ilink_bot_id=${statusResponse.ilink_bot_id} ilink_user_id=${redactToken(statusResponse.ilink_user_id)}`);
500
+ return {
501
+ connected: true,
502
+ botToken: statusResponse.bot_token,
503
+ accountId: statusResponse.ilink_bot_id,
504
+ baseUrl: statusResponse.baseurl,
505
+ userId: statusResponse.ilink_user_id,
506
+ message: "✅ 与微信连接成功!"
507
+ };
508
+ }
509
+ } catch (err) {
510
+ logger.error(`Error polling QR status: ${String(err)}`);
511
+ activeLogins.delete(opts.sessionKey);
512
+ return {
513
+ connected: false,
514
+ message: `Login failed: ${String(err)}`
515
+ };
516
+ }
517
+ await new Promise((r) => setTimeout(r, 1e3));
518
+ }
519
+ logger.warn(`waitForWeixinLogin: timed out waiting for QR scan sessionKey=${opts.sessionKey} timeoutMs=${timeoutMs}`);
520
+ activeLogins.delete(opts.sessionKey);
521
+ return {
522
+ connected: false,
523
+ message: "登录超时,请重试。"
524
+ };
525
+ }
526
+ //#endregion
527
+ //#region src/api/api.ts
528
+ function readChannelVersion() {
529
+ try {
530
+ const dir = path.dirname(fileURLToPath(import.meta.url));
531
+ const pkgPath = path.resolve(dir, "..", "..", "package.json");
532
+ return JSON.parse(fs.readFileSync(pkgPath, "utf-8")).version ?? "unknown";
533
+ } catch {
534
+ return "unknown";
535
+ }
536
+ }
537
+ const CHANNEL_VERSION = readChannelVersion();
538
+ /** Build the `base_info` payload included in every API request. */
539
+ function buildBaseInfo() {
540
+ return { channel_version: CHANNEL_VERSION };
541
+ }
542
+ /** Default timeout for long-poll getUpdates requests. */
543
+ const DEFAULT_LONG_POLL_TIMEOUT_MS$1 = 35e3;
544
+ /** Default timeout for regular API requests (sendMessage, getUploadUrl). */
545
+ const DEFAULT_API_TIMEOUT_MS = 15e3;
546
+ /** Default timeout for lightweight API requests (getConfig, sendTyping). */
547
+ const DEFAULT_CONFIG_TIMEOUT_MS = 1e4;
548
+ function ensureTrailingSlash(url) {
549
+ return url.endsWith("/") ? url : `${url}/`;
550
+ }
551
+ /** X-WECHAT-UIN header: random uint32 -> decimal string -> base64. */
552
+ function randomWechatUin() {
553
+ const uint32 = crypto.randomBytes(4).readUInt32BE(0);
554
+ return Buffer.from(String(uint32), "utf-8").toString("base64");
555
+ }
556
+ function buildHeaders(opts) {
557
+ const headers = {
558
+ "Content-Type": "application/json",
559
+ AuthorizationType: "ilink_bot_token",
560
+ "Content-Length": String(Buffer.byteLength(opts.body, "utf-8")),
561
+ "X-WECHAT-UIN": randomWechatUin()
562
+ };
563
+ if (opts.token?.trim()) headers.Authorization = `Bearer ${opts.token.trim()}`;
564
+ const routeTag = loadConfigRouteTag();
565
+ if (routeTag) headers.SKRouteTag = routeTag;
566
+ logger.debug(`requestHeaders: ${JSON.stringify({
567
+ ...headers,
568
+ Authorization: headers.Authorization ? "Bearer ***" : void 0
569
+ })}`);
570
+ return headers;
571
+ }
572
+ /**
573
+ * Common fetch wrapper: POST JSON to a Weixin API endpoint with timeout + abort.
574
+ * Returns the raw response text on success; throws on HTTP error or timeout.
575
+ */
576
+ async function apiFetch(params) {
577
+ const base = ensureTrailingSlash(params.baseUrl);
578
+ const url = new URL(params.endpoint, base);
579
+ const hdrs = buildHeaders({
580
+ token: params.token,
581
+ body: params.body
582
+ });
583
+ logger.debug(`POST ${redactUrl(url.toString())} body=${redactBody(params.body)}`);
584
+ const controller = new AbortController();
585
+ const t = setTimeout(() => controller.abort(), params.timeoutMs);
586
+ try {
587
+ const res = await fetch(url.toString(), {
588
+ method: "POST",
589
+ headers: hdrs,
590
+ body: params.body,
591
+ signal: controller.signal
592
+ });
593
+ clearTimeout(t);
594
+ const rawText = await res.text();
595
+ logger.debug(`${params.label} status=${res.status} raw=${redactBody(rawText)}`);
596
+ if (!res.ok) throw new Error(`${params.label} ${res.status}: ${rawText}`);
597
+ return rawText;
598
+ } catch (err) {
599
+ clearTimeout(t);
600
+ throw err;
601
+ }
602
+ }
603
+ /**
604
+ * Long-poll getUpdates. Server should hold the request until new messages or timeout.
605
+ *
606
+ * On client-side timeout (no server response within timeoutMs), returns an empty response
607
+ * with ret=0 so the caller can simply retry. This is normal for long-poll.
608
+ */
609
+ async function getUpdates(params) {
610
+ const timeout = params.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS$1;
611
+ try {
612
+ const rawText = await apiFetch({
613
+ baseUrl: params.baseUrl,
614
+ endpoint: "ilink/bot/getupdates",
615
+ body: JSON.stringify({
616
+ get_updates_buf: params.get_updates_buf ?? "",
617
+ base_info: buildBaseInfo()
618
+ }),
619
+ token: params.token,
620
+ timeoutMs: timeout,
621
+ label: "getUpdates"
622
+ });
623
+ return JSON.parse(rawText);
624
+ } catch (err) {
625
+ if (err instanceof Error && err.name === "AbortError") {
626
+ logger.debug(`getUpdates: client-side timeout after ${timeout}ms, returning empty response`);
627
+ return {
628
+ ret: 0,
629
+ msgs: [],
630
+ get_updates_buf: params.get_updates_buf
631
+ };
632
+ }
633
+ throw err;
634
+ }
635
+ }
636
+ /** Get a pre-signed CDN upload URL for a file. */
637
+ async function getUploadUrl(params) {
638
+ const rawText = await apiFetch({
639
+ baseUrl: params.baseUrl,
640
+ endpoint: "ilink/bot/getuploadurl",
641
+ body: JSON.stringify({
642
+ filekey: params.filekey,
643
+ media_type: params.media_type,
644
+ to_user_id: params.to_user_id,
645
+ rawsize: params.rawsize,
646
+ rawfilemd5: params.rawfilemd5,
647
+ filesize: params.filesize,
648
+ thumb_rawsize: params.thumb_rawsize,
649
+ thumb_rawfilemd5: params.thumb_rawfilemd5,
650
+ thumb_filesize: params.thumb_filesize,
651
+ no_need_thumb: params.no_need_thumb,
652
+ aeskey: params.aeskey,
653
+ base_info: buildBaseInfo()
654
+ }),
655
+ token: params.token,
656
+ timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
657
+ label: "getUploadUrl"
658
+ });
659
+ return JSON.parse(rawText);
660
+ }
661
+ /** Send a single message downstream. */
662
+ async function sendMessage(params) {
663
+ await apiFetch({
664
+ baseUrl: params.baseUrl,
665
+ endpoint: "ilink/bot/sendmessage",
666
+ body: JSON.stringify({
667
+ ...params.body,
668
+ base_info: buildBaseInfo()
669
+ }),
670
+ token: params.token,
671
+ timeoutMs: params.timeoutMs ?? DEFAULT_API_TIMEOUT_MS,
672
+ label: "sendMessage"
673
+ });
674
+ }
675
+ /** Fetch bot config (includes typing_ticket) for a given user. */
676
+ async function getConfig(params) {
677
+ const rawText = await apiFetch({
678
+ baseUrl: params.baseUrl,
679
+ endpoint: "ilink/bot/getconfig",
680
+ body: JSON.stringify({
681
+ ilink_user_id: params.ilinkUserId,
682
+ context_token: params.contextToken,
683
+ base_info: buildBaseInfo()
684
+ }),
685
+ token: params.token,
686
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
687
+ label: "getConfig"
688
+ });
689
+ return JSON.parse(rawText);
690
+ }
691
+ /** Send a typing indicator to a user. */
692
+ async function sendTyping(params) {
693
+ await apiFetch({
694
+ baseUrl: params.baseUrl,
695
+ endpoint: "ilink/bot/sendtyping",
696
+ body: JSON.stringify({
697
+ ...params.body,
698
+ base_info: buildBaseInfo()
699
+ }),
700
+ token: params.token,
701
+ timeoutMs: params.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS,
702
+ label: "sendTyping"
703
+ });
704
+ }
705
+ //#endregion
706
+ //#region src/api/config-cache.ts
707
+ const CONFIG_CACHE_TTL_MS = 1440 * 60 * 1e3;
708
+ const CONFIG_CACHE_INITIAL_RETRY_MS = 2e3;
709
+ const CONFIG_CACHE_MAX_RETRY_MS = 3600 * 1e3;
710
+ /**
711
+ * Per-user getConfig cache with periodic random refresh (within 24h) and
712
+ * exponential-backoff retry (up to 1h) on failure.
713
+ */
714
+ var WeixinConfigManager = class {
715
+ cache = /* @__PURE__ */ new Map();
716
+ constructor(apiOpts, log) {
717
+ this.apiOpts = apiOpts;
718
+ this.log = log;
719
+ }
720
+ async getForUser(userId, contextToken) {
721
+ const now = Date.now();
722
+ const entry = this.cache.get(userId);
723
+ if (!entry || now >= entry.nextFetchAt) {
724
+ let fetchOk = false;
725
+ try {
726
+ const resp = await getConfig({
727
+ baseUrl: this.apiOpts.baseUrl,
728
+ token: this.apiOpts.token,
729
+ ilinkUserId: userId,
730
+ contextToken
731
+ });
732
+ if (resp.ret === 0) {
733
+ this.cache.set(userId, {
734
+ config: { typingTicket: resp.typing_ticket ?? "" },
735
+ everSucceeded: true,
736
+ nextFetchAt: now + Math.random() * CONFIG_CACHE_TTL_MS,
737
+ retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS
738
+ });
739
+ this.log(`[weixin] config ${entry?.everSucceeded ? "refreshed" : "cached"} for ${userId}`);
740
+ fetchOk = true;
741
+ }
742
+ } catch (err) {
743
+ this.log(`[weixin] getConfig failed for ${userId} (ignored): ${String(err)}`);
744
+ }
745
+ if (!fetchOk) {
746
+ const prevDelay = entry?.retryDelayMs ?? CONFIG_CACHE_INITIAL_RETRY_MS;
747
+ const nextDelay = Math.min(prevDelay * 2, CONFIG_CACHE_MAX_RETRY_MS);
748
+ if (entry) {
749
+ entry.nextFetchAt = now + nextDelay;
750
+ entry.retryDelayMs = nextDelay;
751
+ } else this.cache.set(userId, {
752
+ config: { typingTicket: "" },
753
+ everSucceeded: false,
754
+ nextFetchAt: now + CONFIG_CACHE_INITIAL_RETRY_MS,
755
+ retryDelayMs: CONFIG_CACHE_INITIAL_RETRY_MS
756
+ });
757
+ }
758
+ }
759
+ return this.cache.get(userId)?.config ?? { typingTicket: "" };
760
+ }
761
+ };
762
+ //#endregion
763
+ //#region src/api/session-guard.ts
764
+ const SESSION_PAUSE_DURATION_MS = 3600 * 1e3;
765
+ const pauseUntilMap = /* @__PURE__ */ new Map();
766
+ /** Pause all inbound/outbound API calls for `accountId` for one hour. */
767
+ function pauseSession(accountId) {
768
+ const until = Date.now() + SESSION_PAUSE_DURATION_MS;
769
+ pauseUntilMap.set(accountId, until);
770
+ logger.info(`session-guard: paused accountId=${accountId} until=${new Date(until).toISOString()} (${SESSION_PAUSE_DURATION_MS / 1e3}s)`);
771
+ }
772
+ /** Milliseconds remaining until the pause expires (0 when not paused). */
773
+ function getRemainingPauseMs(accountId) {
774
+ const until = pauseUntilMap.get(accountId);
775
+ if (until === void 0) return 0;
776
+ const remaining = until - Date.now();
777
+ if (remaining <= 0) {
778
+ pauseUntilMap.delete(accountId);
779
+ return 0;
780
+ }
781
+ return remaining;
782
+ }
783
+ //#endregion
784
+ //#region src/api/types.ts
785
+ /** proto: UploadMediaType */
786
+ const UploadMediaType = {
787
+ IMAGE: 1,
788
+ VIDEO: 2,
789
+ FILE: 3,
790
+ VOICE: 4
791
+ };
792
+ const MessageType = {
793
+ NONE: 0,
794
+ USER: 1,
795
+ BOT: 2
796
+ };
797
+ const MessageItemType = {
798
+ NONE: 0,
799
+ TEXT: 1,
800
+ IMAGE: 2,
801
+ VOICE: 3,
802
+ FILE: 4,
803
+ VIDEO: 5
804
+ };
805
+ const MessageState = {
806
+ NEW: 0,
807
+ GENERATING: 1,
808
+ FINISH: 2
809
+ };
810
+ /** Typing status: 1 = typing (default), 2 = cancel typing. */
811
+ const TypingStatus = {
812
+ TYPING: 1,
813
+ CANCEL: 2
814
+ };
815
+ //#endregion
816
+ //#region src/cdn/aes-ecb.ts
817
+ /**
818
+ * Shared AES-128-ECB crypto utilities for CDN upload and download.
819
+ */
820
+ /** Encrypt buffer with AES-128-ECB (PKCS7 padding is default). */
821
+ function encryptAesEcb(plaintext, key) {
822
+ const cipher = createCipheriv("aes-128-ecb", key, null);
823
+ return Buffer.concat([cipher.update(plaintext), cipher.final()]);
824
+ }
825
+ /** Decrypt buffer with AES-128-ECB (PKCS7 padding). */
826
+ function decryptAesEcb(ciphertext, key) {
827
+ const decipher = createDecipheriv("aes-128-ecb", key, null);
828
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
829
+ }
830
+ /** Compute AES-128-ECB ciphertext size (PKCS7 padding to 16-byte boundary). */
831
+ function aesEcbPaddedSize(plaintextSize) {
832
+ return Math.ceil((plaintextSize + 1) / 16) * 16;
833
+ }
834
+ //#endregion
835
+ //#region src/cdn/cdn-url.ts
836
+ /**
837
+ * Unified CDN URL construction for Weixin CDN upload/download.
838
+ */
839
+ /** Build a CDN download URL from encrypt_query_param. */
840
+ function buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl) {
841
+ return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`;
842
+ }
843
+ /** Build a CDN upload URL from upload_param and filekey. */
844
+ function buildCdnUploadUrl(params) {
845
+ return `${params.cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(params.uploadParam)}&filekey=${encodeURIComponent(params.filekey)}`;
846
+ }
847
+ //#endregion
848
+ //#region src/cdn/cdn-upload.ts
849
+ /** Maximum retry attempts for CDN upload. */
850
+ const UPLOAD_MAX_RETRIES = 3;
851
+ /**
852
+ * Upload one buffer to the Weixin CDN with AES-128-ECB encryption.
853
+ * Returns the download encrypted_query_param from the CDN response.
854
+ * Retries up to UPLOAD_MAX_RETRIES times on server errors; client errors (4xx) abort immediately.
855
+ */
856
+ async function uploadBufferToCdn(params) {
857
+ const { buf, uploadParam, filekey, cdnBaseUrl, label, aeskey } = params;
858
+ const ciphertext = encryptAesEcb(buf, aeskey);
859
+ const cdnUrl = buildCdnUploadUrl({
860
+ cdnBaseUrl,
861
+ uploadParam,
862
+ filekey
863
+ });
864
+ logger.debug(`${label}: CDN POST url=${redactUrl(cdnUrl)} ciphertextSize=${ciphertext.length}`);
865
+ let downloadParam;
866
+ let lastError;
867
+ for (let attempt = 1; attempt <= UPLOAD_MAX_RETRIES; attempt++) try {
868
+ const res = await fetch(cdnUrl, {
869
+ method: "POST",
870
+ headers: { "Content-Type": "application/octet-stream" },
871
+ body: new Uint8Array(ciphertext)
872
+ });
873
+ if (res.status >= 400 && res.status < 500) {
874
+ const errMsg = res.headers.get("x-error-message") ?? await res.text();
875
+ logger.error(`${label}: CDN client error attempt=${attempt} status=${res.status} errMsg=${errMsg}`);
876
+ throw new Error(`CDN upload client error ${res.status}: ${errMsg}`);
877
+ }
878
+ if (res.status !== 200) {
879
+ const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
880
+ logger.error(`${label}: CDN server error attempt=${attempt} status=${res.status} errMsg=${errMsg}`);
881
+ throw new Error(`CDN upload server error: ${errMsg}`);
882
+ }
883
+ downloadParam = res.headers.get("x-encrypted-param") ?? void 0;
884
+ if (!downloadParam) {
885
+ logger.error(`${label}: CDN response missing x-encrypted-param header attempt=${attempt}`);
886
+ throw new Error("CDN upload response missing x-encrypted-param header");
887
+ }
888
+ logger.debug(`${label}: CDN upload success attempt=${attempt}`);
889
+ break;
890
+ } catch (err) {
891
+ lastError = err;
892
+ if (err instanceof Error && err.message.includes("client error")) throw err;
893
+ if (attempt < UPLOAD_MAX_RETRIES) logger.error(`${label}: attempt ${attempt} failed, retrying... err=${String(err)}`);
894
+ else logger.error(`${label}: all ${UPLOAD_MAX_RETRIES} attempts failed err=${String(err)}`);
895
+ }
896
+ if (!downloadParam) throw lastError instanceof Error ? lastError : /* @__PURE__ */ new Error(`CDN upload failed after ${UPLOAD_MAX_RETRIES} attempts`);
897
+ return { downloadParam };
898
+ }
899
+ //#endregion
900
+ //#region src/media/mime.ts
901
+ const EXTENSION_TO_MIME = {
902
+ ".pdf": "application/pdf",
903
+ ".doc": "application/msword",
904
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
905
+ ".xls": "application/vnd.ms-excel",
906
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
907
+ ".ppt": "application/vnd.ms-powerpoint",
908
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
909
+ ".txt": "text/plain",
910
+ ".csv": "text/csv",
911
+ ".zip": "application/zip",
912
+ ".tar": "application/x-tar",
913
+ ".gz": "application/gzip",
914
+ ".mp3": "audio/mpeg",
915
+ ".ogg": "audio/ogg",
916
+ ".wav": "audio/wav",
917
+ ".mp4": "video/mp4",
918
+ ".mov": "video/quicktime",
919
+ ".webm": "video/webm",
920
+ ".mkv": "video/x-matroska",
921
+ ".avi": "video/x-msvideo",
922
+ ".png": "image/png",
923
+ ".jpg": "image/jpeg",
924
+ ".jpeg": "image/jpeg",
925
+ ".gif": "image/gif",
926
+ ".webp": "image/webp",
927
+ ".bmp": "image/bmp"
928
+ };
929
+ const MIME_TO_EXTENSION = {
930
+ "image/jpeg": ".jpg",
931
+ "image/jpg": ".jpg",
932
+ "image/png": ".png",
933
+ "image/gif": ".gif",
934
+ "image/webp": ".webp",
935
+ "image/bmp": ".bmp",
936
+ "video/mp4": ".mp4",
937
+ "video/quicktime": ".mov",
938
+ "video/webm": ".webm",
939
+ "video/x-matroska": ".mkv",
940
+ "video/x-msvideo": ".avi",
941
+ "audio/mpeg": ".mp3",
942
+ "audio/ogg": ".ogg",
943
+ "audio/wav": ".wav",
944
+ "application/pdf": ".pdf",
945
+ "application/zip": ".zip",
946
+ "application/x-tar": ".tar",
947
+ "application/gzip": ".gz",
948
+ "text/plain": ".txt",
949
+ "text/csv": ".csv"
950
+ };
951
+ /** Get MIME type from filename extension. Returns "application/octet-stream" for unknown extensions. */
952
+ function getMimeFromFilename(filename) {
953
+ return EXTENSION_TO_MIME[path.extname(filename).toLowerCase()] ?? "application/octet-stream";
954
+ }
955
+ /** Get file extension from MIME type. Returns ".bin" for unknown types. */
956
+ function getExtensionFromMime(mimeType) {
957
+ return MIME_TO_EXTENSION[mimeType.split(";")[0].trim().toLowerCase()] ?? ".bin";
958
+ }
959
+ /** Get file extension from Content-Type header or URL path. Returns ".bin" for unknown. */
960
+ function getExtensionFromContentTypeOrUrl(contentType, url) {
961
+ if (contentType) {
962
+ const ext = getExtensionFromMime(contentType);
963
+ if (ext !== ".bin") return ext;
964
+ }
965
+ const ext = path.extname(new URL(url).pathname).toLowerCase();
966
+ return new Set(Object.keys(EXTENSION_TO_MIME)).has(ext) ? ext : ".bin";
967
+ }
968
+ //#endregion
969
+ //#region src/util/random.ts
970
+ /**
971
+ * Generate a prefixed unique ID using timestamp + crypto random bytes.
972
+ * Format: `{prefix}:{timestamp}-{8-char hex}`
973
+ */
974
+ function generateId(prefix) {
975
+ return `${prefix}:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
976
+ }
977
+ /**
978
+ * Generate a temporary file name with random suffix.
979
+ * Format: `{prefix}-{timestamp}-{8-char hex}{ext}`
980
+ */
981
+ function tempFileName(prefix, ext) {
982
+ return `${prefix}-${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
983
+ }
984
+ //#endregion
985
+ //#region src/cdn/upload.ts
986
+ /**
987
+ * Download a remote media URL (image, video, file) to a local temp file in destDir.
988
+ * Returns the local file path; extension is inferred from Content-Type / URL.
989
+ */
990
+ async function downloadRemoteImageToTemp(url, destDir) {
991
+ logger.debug(`downloadRemoteImageToTemp: fetching url=${url}`);
992
+ const res = await fetch(url);
993
+ if (!res.ok) {
994
+ const msg = `remote media download failed: ${res.status} ${res.statusText} url=${url}`;
995
+ logger.error(`downloadRemoteImageToTemp: ${msg}`);
996
+ throw new Error(msg);
997
+ }
998
+ const buf = Buffer.from(await res.arrayBuffer());
999
+ logger.debug(`downloadRemoteImageToTemp: downloaded ${buf.length} bytes`);
1000
+ await fs$1.mkdir(destDir, { recursive: true });
1001
+ const ext = getExtensionFromContentTypeOrUrl(res.headers.get("content-type"), url);
1002
+ const name = tempFileName("weixin-remote", ext);
1003
+ const filePath = path.join(destDir, name);
1004
+ await fs$1.writeFile(filePath, buf);
1005
+ logger.debug(`downloadRemoteImageToTemp: saved to ${filePath} ext=${ext}`);
1006
+ return filePath;
1007
+ }
1008
+ /**
1009
+ * Common upload pipeline: read file → hash → gen aeskey → getUploadUrl → uploadBufferToCdn → return info.
1010
+ */
1011
+ async function uploadMediaToCdn(params) {
1012
+ const { filePath, toUserId, opts, cdnBaseUrl, mediaType, label } = params;
1013
+ const plaintext = await fs$1.readFile(filePath);
1014
+ const rawsize = plaintext.length;
1015
+ const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
1016
+ const filesize = aesEcbPaddedSize(rawsize);
1017
+ const filekey = crypto.randomBytes(16).toString("hex");
1018
+ const aeskey = crypto.randomBytes(16);
1019
+ logger.debug(`${label}: file=${filePath} rawsize=${rawsize} filesize=${filesize} md5=${rawfilemd5} filekey=${filekey}`);
1020
+ const uploadUrlResp = await getUploadUrl({
1021
+ ...opts,
1022
+ filekey,
1023
+ media_type: mediaType,
1024
+ to_user_id: toUserId,
1025
+ rawsize,
1026
+ rawfilemd5,
1027
+ filesize,
1028
+ no_need_thumb: true,
1029
+ aeskey: aeskey.toString("hex")
1030
+ });
1031
+ const uploadParam = uploadUrlResp.upload_param;
1032
+ if (!uploadParam) {
1033
+ logger.error(`${label}: getUploadUrl returned no upload_param, resp=${JSON.stringify(uploadUrlResp)}`);
1034
+ throw new Error(`${label}: getUploadUrl returned no upload_param`);
1035
+ }
1036
+ const { downloadParam: downloadEncryptedQueryParam } = await uploadBufferToCdn({
1037
+ buf: plaintext,
1038
+ uploadParam,
1039
+ filekey,
1040
+ cdnBaseUrl,
1041
+ aeskey,
1042
+ label: `${label}[orig filekey=${filekey}]`
1043
+ });
1044
+ return {
1045
+ filekey,
1046
+ downloadEncryptedQueryParam,
1047
+ aeskey: aeskey.toString("hex"),
1048
+ fileSize: rawsize,
1049
+ fileSizeCiphertext: filesize
1050
+ };
1051
+ }
1052
+ /** Upload a local image file to the Weixin CDN with AES-128-ECB encryption. */
1053
+ async function uploadFileToWeixin(params) {
1054
+ return uploadMediaToCdn({
1055
+ ...params,
1056
+ mediaType: UploadMediaType.IMAGE,
1057
+ label: "uploadFileToWeixin"
1058
+ });
1059
+ }
1060
+ /** Upload a local video file to the Weixin CDN. */
1061
+ async function uploadVideoToWeixin(params) {
1062
+ return uploadMediaToCdn({
1063
+ ...params,
1064
+ mediaType: UploadMediaType.VIDEO,
1065
+ label: "uploadVideoToWeixin"
1066
+ });
1067
+ }
1068
+ /**
1069
+ * Upload a local file attachment (non-image, non-video) to the Weixin CDN.
1070
+ * Uses media_type=FILE; no thumbnail required.
1071
+ */
1072
+ async function uploadFileAttachmentToWeixin(params) {
1073
+ return uploadMediaToCdn({
1074
+ ...params,
1075
+ mediaType: UploadMediaType.FILE,
1076
+ label: "uploadFileAttachmentToWeixin"
1077
+ });
1078
+ }
1079
+ //#endregion
1080
+ //#region src/cdn/pic-decrypt.ts
1081
+ /**
1082
+ * Download raw bytes from the CDN (no decryption).
1083
+ */
1084
+ async function fetchCdnBytes(url, label) {
1085
+ let res;
1086
+ try {
1087
+ res = await fetch(url);
1088
+ } catch (err) {
1089
+ const cause = err.cause ?? err.code ?? "(no cause)";
1090
+ logger.error(`${label}: fetch network error url=${url} err=${String(err)} cause=${String(cause)}`);
1091
+ throw err;
1092
+ }
1093
+ logger.debug(`${label}: response status=${res.status} ok=${res.ok}`);
1094
+ if (!res.ok) {
1095
+ const body = await res.text().catch(() => "(unreadable)");
1096
+ const msg = `${label}: CDN download ${res.status} ${res.statusText} body=${body}`;
1097
+ logger.error(msg);
1098
+ throw new Error(msg);
1099
+ }
1100
+ return Buffer.from(await res.arrayBuffer());
1101
+ }
1102
+ /**
1103
+ * Parse CDNMedia.aes_key into a raw 16-byte AES key.
1104
+ *
1105
+ * Two encodings are seen in the wild:
1106
+ * - base64(raw 16 bytes) → images (aes_key from media field)
1107
+ * - base64(hex string of 16 bytes) → file / voice / video
1108
+ *
1109
+ * In the second case, base64-decoding yields 32 ASCII hex chars which must
1110
+ * then be parsed as hex to recover the actual 16-byte key.
1111
+ */
1112
+ function parseAesKey(aesKeyBase64, label) {
1113
+ const decoded = Buffer.from(aesKeyBase64, "base64");
1114
+ if (decoded.length === 16) return decoded;
1115
+ if (decoded.length === 32 && /^[0-9a-fA-F]{32}$/.test(decoded.toString("ascii"))) return Buffer.from(decoded.toString("ascii"), "hex");
1116
+ const msg = `${label}: aes_key must decode to 16 raw bytes or 32-char hex string, got ${decoded.length} bytes (base64="${aesKeyBase64}")`;
1117
+ logger.error(msg);
1118
+ throw new Error(msg);
1119
+ }
1120
+ /**
1121
+ * Download and AES-128-ECB decrypt a CDN media file. Returns plaintext Buffer.
1122
+ * aesKeyBase64: CDNMedia.aes_key JSON field (see parseAesKey for supported formats).
1123
+ */
1124
+ async function downloadAndDecryptBuffer(encryptedQueryParam, aesKeyBase64, cdnBaseUrl, label) {
1125
+ const key = parseAesKey(aesKeyBase64, label);
1126
+ const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
1127
+ logger.debug(`${label}: fetching url=${url}`);
1128
+ const encrypted = await fetchCdnBytes(url, label);
1129
+ logger.debug(`${label}: downloaded ${encrypted.byteLength} bytes, decrypting`);
1130
+ const decrypted = decryptAesEcb(encrypted, key);
1131
+ logger.debug(`${label}: decrypted ${decrypted.length} bytes`);
1132
+ return decrypted;
1133
+ }
1134
+ /**
1135
+ * Download plain (unencrypted) bytes from the CDN. Returns the raw Buffer.
1136
+ */
1137
+ async function downloadPlainCdnBuffer(encryptedQueryParam, cdnBaseUrl, label) {
1138
+ const url = buildCdnDownloadUrl(encryptedQueryParam, cdnBaseUrl);
1139
+ logger.debug(`${label}: fetching url=${url}`);
1140
+ return fetchCdnBytes(url, label);
1141
+ }
1142
+ //#endregion
1143
+ //#region src/media/silk-transcode.ts
1144
+ /** Default sample rate for Weixin voice messages. */
1145
+ const SILK_SAMPLE_RATE = 24e3;
1146
+ /**
1147
+ * Wrap raw pcm_s16le bytes in a WAV container.
1148
+ * Mono channel, 16-bit signed little-endian.
1149
+ */
1150
+ function pcmBytesToWav(pcm, sampleRate) {
1151
+ const pcmBytes = pcm.byteLength;
1152
+ const totalSize = 44 + pcmBytes;
1153
+ const buf = Buffer.allocUnsafe(totalSize);
1154
+ let offset = 0;
1155
+ buf.write("RIFF", offset);
1156
+ offset += 4;
1157
+ buf.writeUInt32LE(totalSize - 8, offset);
1158
+ offset += 4;
1159
+ buf.write("WAVE", offset);
1160
+ offset += 4;
1161
+ buf.write("fmt ", offset);
1162
+ offset += 4;
1163
+ buf.writeUInt32LE(16, offset);
1164
+ offset += 4;
1165
+ buf.writeUInt16LE(1, offset);
1166
+ offset += 2;
1167
+ buf.writeUInt16LE(1, offset);
1168
+ offset += 2;
1169
+ buf.writeUInt32LE(sampleRate, offset);
1170
+ offset += 4;
1171
+ buf.writeUInt32LE(sampleRate * 2, offset);
1172
+ offset += 4;
1173
+ buf.writeUInt16LE(2, offset);
1174
+ offset += 2;
1175
+ buf.writeUInt16LE(16, offset);
1176
+ offset += 2;
1177
+ buf.write("data", offset);
1178
+ offset += 4;
1179
+ buf.writeUInt32LE(pcmBytes, offset);
1180
+ offset += 4;
1181
+ Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).copy(buf, offset);
1182
+ return buf;
1183
+ }
1184
+ /**
1185
+ * Try to transcode a SILK audio buffer to WAV using silk-wasm.
1186
+ * silk-wasm's decode() returns { data: Uint8Array (pcm_s16le), duration: number }.
1187
+ *
1188
+ * Returns a WAV Buffer on success, or null if silk-wasm is unavailable or decoding fails.
1189
+ * Callers should fall back to passing the raw SILK file when null is returned.
1190
+ */
1191
+ async function silkToWav(silkBuf) {
1192
+ try {
1193
+ const { decode } = await import("silk-wasm");
1194
+ logger.debug(`silkToWav: decoding ${silkBuf.length} bytes of SILK`);
1195
+ const result = await decode(silkBuf, SILK_SAMPLE_RATE);
1196
+ logger.debug(`silkToWav: decoded duration=${result.duration}ms pcmBytes=${result.data.byteLength}`);
1197
+ const wav = pcmBytesToWav(result.data, SILK_SAMPLE_RATE);
1198
+ logger.debug(`silkToWav: WAV size=${wav.length}`);
1199
+ return wav;
1200
+ } catch (err) {
1201
+ logger.warn(`silkToWav: transcode failed, will use raw silk err=${String(err)}`);
1202
+ return null;
1203
+ }
1204
+ }
1205
+ //#endregion
1206
+ //#region src/media/media-download.ts
1207
+ const WEIXIN_MEDIA_MAX_BYTES = 100 * 1024 * 1024;
1208
+ /**
1209
+ * Download and decrypt media from a single MessageItem.
1210
+ * Returns the populated WeixinInboundMediaOpts fields; empty object on unsupported type or failure.
1211
+ */
1212
+ async function downloadMediaFromItem(item, deps) {
1213
+ const { cdnBaseUrl, saveMedia, log, errLog, label } = deps;
1214
+ const result = {};
1215
+ if (item.type === MessageItemType.IMAGE) {
1216
+ const img = item.image_item;
1217
+ if (!img?.media?.encrypt_query_param) return result;
1218
+ const aesKeyBase64 = img.aeskey ? Buffer.from(img.aeskey, "hex").toString("base64") : img.media.aes_key;
1219
+ logger.debug(`${label} image: encrypt_query_param=${img.media.encrypt_query_param.slice(0, 40)}... hasAesKey=${Boolean(aesKeyBase64)} aeskeySource=${img.aeskey ? "image_item.aeskey" : "media.aes_key"}`);
1220
+ try {
1221
+ const saved = await saveMedia(aesKeyBase64 ? await downloadAndDecryptBuffer(img.media.encrypt_query_param, aesKeyBase64, cdnBaseUrl, `${label} image`) : await downloadPlainCdnBuffer(img.media.encrypt_query_param, cdnBaseUrl, `${label} image-plain`), void 0, "inbound", WEIXIN_MEDIA_MAX_BYTES);
1222
+ result.decryptedPicPath = saved.path;
1223
+ logger.debug(`${label} image saved: ${saved.path}`);
1224
+ } catch (err) {
1225
+ logger.error(`${label} image download/decrypt failed: ${String(err)}`);
1226
+ errLog(`weixin ${label} image download/decrypt failed: ${String(err)}`);
1227
+ }
1228
+ } else if (item.type === MessageItemType.VOICE) {
1229
+ const voice = item.voice_item;
1230
+ if (!voice?.media?.encrypt_query_param || !voice.media.aes_key) return result;
1231
+ try {
1232
+ const silkBuf = await downloadAndDecryptBuffer(voice.media.encrypt_query_param, voice.media.aes_key, cdnBaseUrl, `${label} voice`);
1233
+ logger.debug(`${label} voice: decrypted ${silkBuf.length} bytes, attempting silk transcode`);
1234
+ const wavBuf = await silkToWav(silkBuf);
1235
+ if (wavBuf) {
1236
+ const saved = await saveMedia(wavBuf, "audio/wav", "inbound", WEIXIN_MEDIA_MAX_BYTES);
1237
+ result.decryptedVoicePath = saved.path;
1238
+ result.voiceMediaType = "audio/wav";
1239
+ logger.debug(`${label} voice: saved WAV to ${saved.path}`);
1240
+ } else {
1241
+ const saved = await saveMedia(silkBuf, "audio/silk", "inbound", WEIXIN_MEDIA_MAX_BYTES);
1242
+ result.decryptedVoicePath = saved.path;
1243
+ result.voiceMediaType = "audio/silk";
1244
+ logger.debug(`${label} voice: silk transcode unavailable, saved raw SILK to ${saved.path}`);
1245
+ }
1246
+ } catch (err) {
1247
+ logger.error(`${label} voice download/transcode failed: ${String(err)}`);
1248
+ errLog(`weixin ${label} voice download/transcode failed: ${String(err)}`);
1249
+ }
1250
+ } else if (item.type === MessageItemType.FILE) {
1251
+ const fileItem = item.file_item;
1252
+ if (!fileItem?.media?.encrypt_query_param || !fileItem.media.aes_key) return result;
1253
+ try {
1254
+ const buf = await downloadAndDecryptBuffer(fileItem.media.encrypt_query_param, fileItem.media.aes_key, cdnBaseUrl, `${label} file`);
1255
+ const mime = getMimeFromFilename(fileItem.file_name ?? "file.bin");
1256
+ const saved = await saveMedia(buf, mime, "inbound", WEIXIN_MEDIA_MAX_BYTES, fileItem.file_name ?? void 0);
1257
+ result.decryptedFilePath = saved.path;
1258
+ result.fileMediaType = mime;
1259
+ logger.debug(`${label} file: saved to ${saved.path} mime=${mime}`);
1260
+ } catch (err) {
1261
+ logger.error(`${label} file download failed: ${String(err)}`);
1262
+ errLog(`weixin ${label} file download failed: ${String(err)}`);
1263
+ }
1264
+ } else if (item.type === MessageItemType.VIDEO) {
1265
+ const videoItem = item.video_item;
1266
+ if (!videoItem?.media?.encrypt_query_param || !videoItem.media.aes_key) return result;
1267
+ try {
1268
+ const saved = await saveMedia(await downloadAndDecryptBuffer(videoItem.media.encrypt_query_param, videoItem.media.aes_key, cdnBaseUrl, `${label} video`), "video/mp4", "inbound", WEIXIN_MEDIA_MAX_BYTES);
1269
+ result.decryptedVideoPath = saved.path;
1270
+ logger.debug(`${label} video: saved to ${saved.path}`);
1271
+ } catch (err) {
1272
+ logger.error(`${label} video download failed: ${String(err)}`);
1273
+ errLog(`weixin ${label} video download failed: ${String(err)}`);
1274
+ }
1275
+ }
1276
+ return result;
1277
+ }
1278
+ //#endregion
1279
+ //#region src/messaging/inbound.ts
1280
+ /**
1281
+ * contextToken is issued per-message by the Weixin getupdates API and must
1282
+ * be echoed verbatim in every outbound send. It is not persisted: the monitor
1283
+ * loop populates this map on each inbound message, and the outbound adapter
1284
+ * reads it back when the agent sends a reply.
1285
+ */
1286
+ const contextTokenStore = /* @__PURE__ */ new Map();
1287
+ function contextTokenKey(accountId, userId) {
1288
+ return `${accountId}:${userId}`;
1289
+ }
1290
+ /** Store a context token for a given account+user pair. */
1291
+ function setContextToken(accountId, userId, token) {
1292
+ const k = contextTokenKey(accountId, userId);
1293
+ logger.debug(`setContextToken: key=${k}`);
1294
+ contextTokenStore.set(k, token);
1295
+ }
1296
+ /** Returns true if the message item is a media type (image, video, file, or voice). */
1297
+ function isMediaItem(item) {
1298
+ return item.type === MessageItemType.IMAGE || item.type === MessageItemType.VIDEO || item.type === MessageItemType.FILE || item.type === MessageItemType.VOICE;
1299
+ }
1300
+ function bodyFromItemList(itemList) {
1301
+ if (!itemList?.length) return "";
1302
+ for (const item of itemList) {
1303
+ if (item.type === MessageItemType.TEXT && item.text_item?.text != null) {
1304
+ const text = String(item.text_item.text);
1305
+ const ref = item.ref_msg;
1306
+ if (!ref) return text;
1307
+ if (ref.message_item && isMediaItem(ref.message_item)) return text;
1308
+ const parts = [];
1309
+ if (ref.title) parts.push(ref.title);
1310
+ if (ref.message_item) {
1311
+ const refBody = bodyFromItemList([ref.message_item]);
1312
+ if (refBody) parts.push(refBody);
1313
+ }
1314
+ if (!parts.length) return text;
1315
+ return `[引用: ${parts.join(" | ")}]\n${text}`;
1316
+ }
1317
+ if (item.type === MessageItemType.VOICE && item.voice_item?.text) return item.voice_item.text;
1318
+ }
1319
+ return "";
1320
+ }
1321
+ //#endregion
1322
+ //#region src/messaging/send.ts
1323
+ function generateClientId() {
1324
+ return generateId("openclaw-weixin");
1325
+ }
1326
+ /**
1327
+ * Convert markdown-formatted model reply to plain text for Weixin delivery.
1328
+ * Preserves newlines; strips markdown syntax.
1329
+ */
1330
+ function markdownToPlainText(text) {
1331
+ let result = text;
1332
+ result = result.replace(/```[^\n]*\n?([\s\S]*?)```/g, (_, code) => code.trim());
1333
+ result = result.replace(/!\[[^\]]*\]\([^)]*\)/g, "");
1334
+ result = result.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
1335
+ result = result.replace(/^\|[\s:|-]+\|$/gm, "");
1336
+ result = result.replace(/^\|(.+)\|$/gm, (_, inner) => inner.split("|").map((cell) => cell.trim()).join(" "));
1337
+ result = result.replace(/\*\*(.+?)\*\*/g, "$1").replace(/\*(.+?)\*/g, "$1").replace(/__(.+?)__/g, "$1").replace(/_(.+?)_/g, "$1").replace(/~~(.+?)~~/g, "$1").replace(/`(.+?)`/g, "$1");
1338
+ return result;
1339
+ }
1340
+ /** Build a SendMessageReq containing a single text message. */
1341
+ function buildTextMessageReq(params) {
1342
+ const { to, text, contextToken, clientId } = params;
1343
+ const item_list = text ? [{
1344
+ type: MessageItemType.TEXT,
1345
+ text_item: { text }
1346
+ }] : [];
1347
+ return { msg: {
1348
+ from_user_id: "",
1349
+ to_user_id: to,
1350
+ client_id: clientId,
1351
+ message_type: MessageType.BOT,
1352
+ message_state: MessageState.FINISH,
1353
+ item_list: item_list.length ? item_list : void 0,
1354
+ context_token: contextToken ?? void 0
1355
+ } };
1356
+ }
1357
+ /** Build a SendMessageReq from a text payload. */
1358
+ function buildSendMessageReq(params) {
1359
+ const { to, contextToken, text, clientId } = params;
1360
+ return buildTextMessageReq({
1361
+ to,
1362
+ text,
1363
+ contextToken,
1364
+ clientId
1365
+ });
1366
+ }
1367
+ /**
1368
+ * Send a plain text message downstream.
1369
+ * contextToken is required for all reply sends; missing it breaks conversation association.
1370
+ */
1371
+ async function sendMessageWeixin(params) {
1372
+ const { to, text, opts } = params;
1373
+ if (!opts.contextToken) {
1374
+ logger.error(`sendMessageWeixin: contextToken missing, refusing to send to=${to}`);
1375
+ throw new Error("sendMessageWeixin: contextToken is required");
1376
+ }
1377
+ const clientId = generateClientId();
1378
+ const req = buildSendMessageReq({
1379
+ to,
1380
+ contextToken: opts.contextToken,
1381
+ text,
1382
+ clientId
1383
+ });
1384
+ try {
1385
+ await sendMessage({
1386
+ baseUrl: opts.baseUrl,
1387
+ token: opts.token,
1388
+ timeoutMs: opts.timeoutMs,
1389
+ body: req
1390
+ });
1391
+ } catch (err) {
1392
+ logger.error(`sendMessageWeixin: failed to=${to} clientId=${clientId} err=${String(err)}`);
1393
+ throw err;
1394
+ }
1395
+ return { messageId: clientId };
1396
+ }
1397
+ /**
1398
+ * Send one or more MessageItems (optionally preceded by a text caption) downstream.
1399
+ * Each item is sent as its own request so that item_list always has exactly one entry.
1400
+ */
1401
+ async function sendMediaItems(params) {
1402
+ const { to, text, mediaItem, opts, label } = params;
1403
+ const items = [];
1404
+ if (text) items.push({
1405
+ type: MessageItemType.TEXT,
1406
+ text_item: { text }
1407
+ });
1408
+ items.push(mediaItem);
1409
+ let lastClientId = "";
1410
+ for (const item of items) {
1411
+ lastClientId = generateClientId();
1412
+ const req = { msg: {
1413
+ from_user_id: "",
1414
+ to_user_id: to,
1415
+ client_id: lastClientId,
1416
+ message_type: MessageType.BOT,
1417
+ message_state: MessageState.FINISH,
1418
+ item_list: [item],
1419
+ context_token: opts.contextToken ?? void 0
1420
+ } };
1421
+ try {
1422
+ await sendMessage({
1423
+ baseUrl: opts.baseUrl,
1424
+ token: opts.token,
1425
+ timeoutMs: opts.timeoutMs,
1426
+ body: req
1427
+ });
1428
+ } catch (err) {
1429
+ logger.error(`${label}: failed to=${to} clientId=${lastClientId} err=${String(err)}`);
1430
+ throw err;
1431
+ }
1432
+ }
1433
+ logger.debug(`${label}: success to=${to} clientId=${lastClientId}`);
1434
+ return { messageId: lastClientId };
1435
+ }
1436
+ /**
1437
+ * Send an image message downstream using a previously uploaded file.
1438
+ * Optionally include a text caption as a separate TEXT item before the image.
1439
+ *
1440
+ * ImageItem fields:
1441
+ * - media.encrypt_query_param: CDN download param
1442
+ * - media.aes_key: AES key, base64-encoded
1443
+ * - mid_size: original ciphertext file size
1444
+ */
1445
+ async function sendImageMessageWeixin(params) {
1446
+ const { to, text, uploaded, opts } = params;
1447
+ if (!opts.contextToken) {
1448
+ logger.error(`sendImageMessageWeixin: contextToken missing, refusing to send to=${to}`);
1449
+ throw new Error("sendImageMessageWeixin: contextToken is required");
1450
+ }
1451
+ logger.debug(`sendImageMessageWeixin: to=${to} filekey=${uploaded.filekey} fileSize=${uploaded.fileSize} aeskey=present`);
1452
+ return sendMediaItems({
1453
+ to,
1454
+ text,
1455
+ mediaItem: {
1456
+ type: MessageItemType.IMAGE,
1457
+ image_item: {
1458
+ media: {
1459
+ encrypt_query_param: uploaded.downloadEncryptedQueryParam,
1460
+ aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
1461
+ encrypt_type: 1
1462
+ },
1463
+ mid_size: uploaded.fileSizeCiphertext
1464
+ }
1465
+ },
1466
+ opts,
1467
+ label: "sendImageMessageWeixin"
1468
+ });
1469
+ }
1470
+ /**
1471
+ * Send a video message downstream using a previously uploaded file.
1472
+ * VideoItem: media (CDN ref), video_size (ciphertext bytes).
1473
+ * Includes an optional text caption sent as a separate TEXT item first.
1474
+ */
1475
+ async function sendVideoMessageWeixin(params) {
1476
+ const { to, text, uploaded, opts } = params;
1477
+ if (!opts.contextToken) {
1478
+ logger.error(`sendVideoMessageWeixin: contextToken missing, refusing to send to=${to}`);
1479
+ throw new Error("sendVideoMessageWeixin: contextToken is required");
1480
+ }
1481
+ return sendMediaItems({
1482
+ to,
1483
+ text,
1484
+ mediaItem: {
1485
+ type: MessageItemType.VIDEO,
1486
+ video_item: {
1487
+ media: {
1488
+ encrypt_query_param: uploaded.downloadEncryptedQueryParam,
1489
+ aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
1490
+ encrypt_type: 1
1491
+ },
1492
+ video_size: uploaded.fileSizeCiphertext
1493
+ }
1494
+ },
1495
+ opts,
1496
+ label: "sendVideoMessageWeixin"
1497
+ });
1498
+ }
1499
+ /**
1500
+ * Send a file attachment downstream using a previously uploaded file.
1501
+ * FileItem: media (CDN ref), file_name, len (plaintext bytes as string).
1502
+ * Includes an optional text caption sent as a separate TEXT item first.
1503
+ */
1504
+ async function sendFileMessageWeixin(params) {
1505
+ const { to, text, fileName, uploaded, opts } = params;
1506
+ if (!opts.contextToken) {
1507
+ logger.error(`sendFileMessageWeixin: contextToken missing, refusing to send to=${to}`);
1508
+ throw new Error("sendFileMessageWeixin: contextToken is required");
1509
+ }
1510
+ return sendMediaItems({
1511
+ to,
1512
+ text,
1513
+ mediaItem: {
1514
+ type: MessageItemType.FILE,
1515
+ file_item: {
1516
+ media: {
1517
+ encrypt_query_param: uploaded.downloadEncryptedQueryParam,
1518
+ aes_key: Buffer.from(uploaded.aeskey).toString("base64"),
1519
+ encrypt_type: 1
1520
+ },
1521
+ file_name: fileName,
1522
+ len: String(uploaded.fileSize)
1523
+ }
1524
+ },
1525
+ opts,
1526
+ label: "sendFileMessageWeixin"
1527
+ });
1528
+ }
1529
+ //#endregion
1530
+ //#region src/messaging/error-notice.ts
1531
+ /**
1532
+ * Send a plain-text error notice back to the user.
1533
+ * Fire-and-forget: errors are logged but never thrown, so callers stay unaffected.
1534
+ * No-op when contextToken is absent (we have no conversation reference to reply into).
1535
+ */
1536
+ async function sendWeixinErrorNotice(params) {
1537
+ if (!params.contextToken) {
1538
+ logger.warn(`sendWeixinErrorNotice: no contextToken for to=${params.to}, cannot notify user`);
1539
+ return;
1540
+ }
1541
+ try {
1542
+ await sendMessageWeixin({
1543
+ to: params.to,
1544
+ text: params.message,
1545
+ opts: {
1546
+ baseUrl: params.baseUrl,
1547
+ token: params.token,
1548
+ contextToken: params.contextToken
1549
+ }
1550
+ });
1551
+ logger.debug(`sendWeixinErrorNotice: sent to=${params.to}`);
1552
+ } catch (err) {
1553
+ params.errLog(`[weixin] sendWeixinErrorNotice failed to=${params.to}: ${String(err)}`);
1554
+ }
1555
+ }
1556
+ //#endregion
1557
+ //#region src/messaging/send-media.ts
1558
+ /**
1559
+ * Upload a local file and send it as a weixin message, routing by MIME type:
1560
+ * video/* → uploadVideoToWeixin + sendVideoMessageWeixin
1561
+ * image/* → uploadFileToWeixin + sendImageMessageWeixin
1562
+ * else → uploadFileAttachmentToWeixin + sendFileMessageWeixin
1563
+ *
1564
+ * Used by both the auto-reply deliver path (monitor.ts) and the outbound
1565
+ * sendMedia path (channel.ts) so they stay in sync.
1566
+ */
1567
+ async function sendWeixinMediaFile(params) {
1568
+ const { filePath, to, text, opts, cdnBaseUrl } = params;
1569
+ const mime = getMimeFromFilename(filePath);
1570
+ const uploadOpts = {
1571
+ baseUrl: opts.baseUrl,
1572
+ token: opts.token
1573
+ };
1574
+ if (mime.startsWith("video/")) {
1575
+ logger.info(`[weixin] sendWeixinMediaFile: uploading video filePath=${filePath} to=${to}`);
1576
+ const uploaded = await uploadVideoToWeixin({
1577
+ filePath,
1578
+ toUserId: to,
1579
+ opts: uploadOpts,
1580
+ cdnBaseUrl
1581
+ });
1582
+ logger.info(`[weixin] sendWeixinMediaFile: video upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`);
1583
+ return sendVideoMessageWeixin({
1584
+ to,
1585
+ text,
1586
+ uploaded,
1587
+ opts
1588
+ });
1589
+ }
1590
+ if (mime.startsWith("image/")) {
1591
+ logger.info(`[weixin] sendWeixinMediaFile: uploading image filePath=${filePath} to=${to}`);
1592
+ const uploaded = await uploadFileToWeixin({
1593
+ filePath,
1594
+ toUserId: to,
1595
+ opts: uploadOpts,
1596
+ cdnBaseUrl
1597
+ });
1598
+ logger.info(`[weixin] sendWeixinMediaFile: image upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`);
1599
+ return sendImageMessageWeixin({
1600
+ to,
1601
+ text,
1602
+ uploaded,
1603
+ opts
1604
+ });
1605
+ }
1606
+ const fileName = path.basename(filePath);
1607
+ logger.info(`[weixin] sendWeixinMediaFile: uploading file attachment filePath=${filePath} name=${fileName} to=${to}`);
1608
+ const uploaded = await uploadFileAttachmentToWeixin({
1609
+ filePath,
1610
+ fileName,
1611
+ toUserId: to,
1612
+ opts: uploadOpts,
1613
+ cdnBaseUrl
1614
+ });
1615
+ logger.info(`[weixin] sendWeixinMediaFile: file upload done filekey=${uploaded.filekey} size=${uploaded.fileSize}`);
1616
+ return sendFileMessageWeixin({
1617
+ to,
1618
+ text,
1619
+ fileName,
1620
+ uploaded,
1621
+ opts
1622
+ });
1623
+ }
1624
+ //#endregion
1625
+ //#region src/messaging/debug-mode.ts
1626
+ /**
1627
+ * Per-bot debug mode toggle, persisted to disk so it survives gateway restarts.
1628
+ *
1629
+ * State file: `<stateDir>/openclaw-weixin/debug-mode.json`
1630
+ * Format: `{ "accounts": { "<accountId>": true, ... } }`
1631
+ *
1632
+ * When enabled, processOneMessage appends a timing summary after each
1633
+ * AI reply is delivered to the user.
1634
+ */
1635
+ function resolveDebugModePath() {
1636
+ return path.join(resolveStateDir(), "openclaw-weixin", "debug-mode.json");
1637
+ }
1638
+ function loadState() {
1639
+ try {
1640
+ const raw = fs.readFileSync(resolveDebugModePath(), "utf-8");
1641
+ const parsed = JSON.parse(raw);
1642
+ if (parsed && typeof parsed.accounts === "object") return parsed;
1643
+ } catch {}
1644
+ return { accounts: {} };
1645
+ }
1646
+ function saveState(state) {
1647
+ const filePath = resolveDebugModePath();
1648
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
1649
+ fs.writeFileSync(filePath, JSON.stringify(state, null, 2), "utf-8");
1650
+ }
1651
+ /** Toggle debug mode for a bot account. Returns the new state. */
1652
+ function toggleDebugMode(accountId) {
1653
+ const state = loadState();
1654
+ const next = !state.accounts[accountId];
1655
+ state.accounts[accountId] = next;
1656
+ try {
1657
+ saveState(state);
1658
+ } catch (err) {
1659
+ logger.error(`debug-mode: failed to persist state: ${String(err)}`);
1660
+ }
1661
+ return next;
1662
+ }
1663
+ //#endregion
1664
+ //#region src/messaging/slash-commands.ts
1665
+ /** 发送回复消息 */
1666
+ async function sendReply(ctx, text) {
1667
+ const opts = {
1668
+ baseUrl: ctx.baseUrl,
1669
+ token: ctx.token,
1670
+ contextToken: ctx.contextToken
1671
+ };
1672
+ await sendMessageWeixin({
1673
+ to: ctx.to,
1674
+ text,
1675
+ opts
1676
+ });
1677
+ }
1678
+ /** 处理 /echo 指令 */
1679
+ async function handleEcho(ctx, args, receivedAt, eventTimestamp) {
1680
+ const message = args.trim();
1681
+ if (message) await sendReply(ctx, message);
1682
+ const eventTs = eventTimestamp ?? 0;
1683
+ const platformDelay = eventTs > 0 ? `${receivedAt - eventTs}ms` : "N/A";
1684
+ await sendReply(ctx, [
1685
+ "⏱ 通道耗时",
1686
+ `├ 事件时间: ${eventTs > 0 ? new Date(eventTs).toISOString() : "N/A"}`,
1687
+ `├ 平台→插件: ${platformDelay}`,
1688
+ `└ 插件处理: ${Date.now() - receivedAt}ms`
1689
+ ].join("\n"));
1690
+ }
1691
+ /**
1692
+ * 尝试处理斜杠指令
1693
+ *
1694
+ * @returns handled=true 表示该消息已作为指令处理,不需要继续走 AI 管道
1695
+ */
1696
+ async function handleSlashCommand(content, ctx, receivedAt, eventTimestamp) {
1697
+ const trimmed = content.trim();
1698
+ if (!trimmed.startsWith("/")) return { handled: false };
1699
+ const spaceIdx = trimmed.indexOf(" ");
1700
+ const command = spaceIdx === -1 ? trimmed.toLowerCase() : trimmed.slice(0, spaceIdx).toLowerCase();
1701
+ const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1);
1702
+ logger.info(`[weixin] Slash command: ${command}, args: ${args.slice(0, 50)}`);
1703
+ try {
1704
+ switch (command) {
1705
+ case "/echo":
1706
+ await handleEcho(ctx, args, receivedAt, eventTimestamp);
1707
+ return { handled: true };
1708
+ case "/toggle-debug":
1709
+ await sendReply(ctx, toggleDebugMode(ctx.accountId) ? "Debug 模式已开启" : "Debug 模式已关闭");
1710
+ return { handled: true };
1711
+ default: return { handled: false };
1712
+ }
1713
+ } catch (err) {
1714
+ logger.error(`[weixin] Slash command error: ${String(err)}`);
1715
+ try {
1716
+ await sendReply(ctx, `❌ 指令执行失败: ${String(err).slice(0, 200)}`);
1717
+ } catch {}
1718
+ return { handled: true };
1719
+ }
1720
+ }
1721
+ //#endregion
1722
+ //#region src/messaging/process-message.ts
1723
+ const MEDIA_TEMP_DIR = "/tmp/weixin-agent/media";
1724
+ /** Save a buffer to a temporary file, returning the file path. */
1725
+ async function saveMediaBuffer(buffer, contentType, subdir, _maxBytes, originalFilename) {
1726
+ const dir = path.join(MEDIA_TEMP_DIR, subdir ?? "");
1727
+ await fs$1.mkdir(dir, { recursive: true });
1728
+ let ext = ".bin";
1729
+ if (originalFilename) ext = path.extname(originalFilename) || ".bin";
1730
+ else if (contentType) ext = getExtensionFromMime(contentType);
1731
+ const name = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}${ext}`;
1732
+ const filePath = path.join(dir, name);
1733
+ await fs$1.writeFile(filePath, buffer);
1734
+ return { path: filePath };
1735
+ }
1736
+ /** Extract raw text from item_list (for slash command detection). */
1737
+ function extractTextBody(itemList) {
1738
+ if (!itemList?.length) return "";
1739
+ for (const item of itemList) if (item.type === MessageItemType.TEXT && item.text_item?.text != null) return String(item.text_item.text);
1740
+ return "";
1741
+ }
1742
+ /** Find the first downloadable media item from a message. */
1743
+ function findMediaItem(itemList) {
1744
+ if (!itemList?.length) return void 0;
1745
+ const direct = itemList.find((i) => i.type === MessageItemType.IMAGE && i.image_item?.media?.encrypt_query_param) ?? itemList.find((i) => i.type === MessageItemType.VIDEO && i.video_item?.media?.encrypt_query_param) ?? itemList.find((i) => i.type === MessageItemType.FILE && i.file_item?.media?.encrypt_query_param) ?? itemList.find((i) => i.type === MessageItemType.VOICE && i.voice_item?.media?.encrypt_query_param && !i.voice_item.text);
1746
+ if (direct) return direct;
1747
+ return itemList.find((i) => i.type === MessageItemType.TEXT && i.ref_msg?.message_item && isMediaItem(i.ref_msg.message_item))?.ref_msg?.message_item ?? void 0;
1748
+ }
1749
+ /**
1750
+ * Process a single inbound message:
1751
+ * slash command check → download media → call agent → send reply.
1752
+ */
1753
+ async function processOneMessage(full, deps) {
1754
+ const receivedAt = Date.now();
1755
+ const textBody = extractTextBody(full.item_list);
1756
+ if (textBody.startsWith("/")) {
1757
+ if ((await handleSlashCommand(textBody, {
1758
+ to: full.from_user_id ?? "",
1759
+ contextToken: full.context_token,
1760
+ baseUrl: deps.baseUrl,
1761
+ token: deps.token,
1762
+ accountId: deps.accountId,
1763
+ log: deps.log,
1764
+ errLog: deps.errLog
1765
+ }, receivedAt, full.create_time_ms)).handled) return;
1766
+ }
1767
+ const contextToken = full.context_token;
1768
+ if (contextToken) setContextToken(deps.accountId, full.from_user_id ?? "", contextToken);
1769
+ let media;
1770
+ const mediaItem = findMediaItem(full.item_list);
1771
+ if (mediaItem) try {
1772
+ const downloaded = await downloadMediaFromItem(mediaItem, {
1773
+ cdnBaseUrl: deps.cdnBaseUrl,
1774
+ saveMedia: saveMediaBuffer,
1775
+ log: deps.log,
1776
+ errLog: deps.errLog,
1777
+ label: "inbound"
1778
+ });
1779
+ if (downloaded.decryptedPicPath) media = {
1780
+ type: "image",
1781
+ filePath: downloaded.decryptedPicPath,
1782
+ mimeType: "image/*"
1783
+ };
1784
+ else if (downloaded.decryptedVideoPath) media = {
1785
+ type: "video",
1786
+ filePath: downloaded.decryptedVideoPath,
1787
+ mimeType: "video/mp4"
1788
+ };
1789
+ else if (downloaded.decryptedFilePath) media = {
1790
+ type: "file",
1791
+ filePath: downloaded.decryptedFilePath,
1792
+ mimeType: downloaded.fileMediaType ?? "application/octet-stream"
1793
+ };
1794
+ else if (downloaded.decryptedVoicePath) media = {
1795
+ type: "audio",
1796
+ filePath: downloaded.decryptedVoicePath,
1797
+ mimeType: downloaded.voiceMediaType ?? "audio/wav"
1798
+ };
1799
+ } catch (err) {
1800
+ logger.error(`media download failed: ${String(err)}`);
1801
+ }
1802
+ const request = {
1803
+ conversationId: full.from_user_id ?? "",
1804
+ text: bodyFromItemList(full.item_list),
1805
+ media
1806
+ };
1807
+ const to = full.from_user_id ?? "";
1808
+ if (deps.typingTicket) sendTyping({
1809
+ baseUrl: deps.baseUrl,
1810
+ token: deps.token,
1811
+ body: {
1812
+ ilink_user_id: to,
1813
+ typing_ticket: deps.typingTicket,
1814
+ status: TypingStatus.TYPING
1815
+ }
1816
+ }).catch(() => {});
1817
+ try {
1818
+ const response = await deps.agent.chat(request);
1819
+ if (response.media) {
1820
+ let filePath;
1821
+ const mediaUrl = response.media.url;
1822
+ if (mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://")) filePath = await downloadRemoteImageToTemp(mediaUrl, path.join(MEDIA_TEMP_DIR, "outbound"));
1823
+ else filePath = path.isAbsolute(mediaUrl) ? mediaUrl : path.resolve(mediaUrl);
1824
+ await sendWeixinMediaFile({
1825
+ filePath,
1826
+ to,
1827
+ text: response.text ? markdownToPlainText(response.text) : "",
1828
+ opts: {
1829
+ baseUrl: deps.baseUrl,
1830
+ token: deps.token,
1831
+ contextToken
1832
+ },
1833
+ cdnBaseUrl: deps.cdnBaseUrl
1834
+ });
1835
+ } else if (response.text) await sendMessageWeixin({
1836
+ to,
1837
+ text: markdownToPlainText(response.text),
1838
+ opts: {
1839
+ baseUrl: deps.baseUrl,
1840
+ token: deps.token,
1841
+ contextToken
1842
+ }
1843
+ });
1844
+ } catch (err) {
1845
+ logger.error(`processOneMessage: agent or send failed: ${err instanceof Error ? err.stack ?? err.message : JSON.stringify(err)}`);
1846
+ sendWeixinErrorNotice({
1847
+ to,
1848
+ contextToken,
1849
+ message: `⚠️ 处理消息失败:${err instanceof Error ? err.message : JSON.stringify(err)}`,
1850
+ baseUrl: deps.baseUrl,
1851
+ token: deps.token,
1852
+ errLog: deps.errLog
1853
+ });
1854
+ } finally {
1855
+ if (deps.typingTicket) sendTyping({
1856
+ baseUrl: deps.baseUrl,
1857
+ token: deps.token,
1858
+ body: {
1859
+ ilink_user_id: to,
1860
+ typing_ticket: deps.typingTicket,
1861
+ status: TypingStatus.CANCEL
1862
+ }
1863
+ }).catch(() => {});
1864
+ }
1865
+ }
1866
+ //#endregion
1867
+ //#region src/storage/sync-buf.ts
1868
+ function resolveAccountsDir() {
1869
+ return path.join(resolveStateDir(), "openclaw-weixin", "accounts");
1870
+ }
1871
+ /**
1872
+ * Path to the persistent get_updates_buf file for an account.
1873
+ * Stored alongside account data: ~/.openclaw/openclaw-weixin/accounts/{accountId}.sync.json
1874
+ */
1875
+ function getSyncBufFilePath(accountId) {
1876
+ return path.join(resolveAccountsDir(), `${accountId}.sync.json`);
1877
+ }
1878
+ /** Legacy single-account syncbuf (pre multi-account): `.openclaw-weixin-sync/default.json`. */
1879
+ function getLegacySyncBufDefaultJsonPath() {
1880
+ return path.join(resolveStateDir(), "agents", "default", "sessions", ".openclaw-weixin-sync", "default.json");
1881
+ }
1882
+ function readSyncBufFile(filePath) {
1883
+ try {
1884
+ const raw = fs.readFileSync(filePath, "utf-8");
1885
+ const data = JSON.parse(raw);
1886
+ if (typeof data.get_updates_buf === "string") return data.get_updates_buf;
1887
+ } catch {}
1888
+ }
1889
+ /**
1890
+ * Load persisted get_updates_buf.
1891
+ * Falls back in order:
1892
+ * 1. Primary path (normalized accountId, new installs)
1893
+ * 2. Compat path (raw accountId derived from pattern, old installs)
1894
+ * 3. Legacy single-account path (very old installs without multi-account support)
1895
+ */
1896
+ function loadGetUpdatesBuf(filePath) {
1897
+ const value = readSyncBufFile(filePath);
1898
+ if (value !== void 0) return value;
1899
+ const rawId = deriveRawAccountId(path.basename(filePath, ".sync.json"));
1900
+ if (rawId) {
1901
+ const compatValue = readSyncBufFile(path.join(resolveAccountsDir(), `${rawId}.sync.json`));
1902
+ if (compatValue !== void 0) return compatValue;
1903
+ }
1904
+ return readSyncBufFile(getLegacySyncBufDefaultJsonPath());
1905
+ }
1906
+ /**
1907
+ * Persist get_updates_buf. Creates parent dir if needed.
1908
+ */
1909
+ function saveGetUpdatesBuf(filePath, getUpdatesBuf) {
1910
+ const dir = path.dirname(filePath);
1911
+ fs.mkdirSync(dir, { recursive: true });
1912
+ fs.writeFileSync(filePath, JSON.stringify({ get_updates_buf: getUpdatesBuf }, null, 0), "utf-8");
1913
+ }
1914
+ //#endregion
1915
+ //#region src/monitor/monitor.ts
1916
+ const DEFAULT_LONG_POLL_TIMEOUT_MS = 35e3;
1917
+ const MAX_CONSECUTIVE_FAILURES = 3;
1918
+ const BACKOFF_DELAY_MS = 3e4;
1919
+ const RETRY_DELAY_MS = 2e3;
1920
+ /**
1921
+ * Long-poll loop: getUpdates → process message → call agent → send reply.
1922
+ * Runs until aborted.
1923
+ */
1924
+ async function monitorWeixinProvider(opts) {
1925
+ const { baseUrl, cdnBaseUrl, token, accountId, agent, abortSignal, longPollTimeoutMs } = opts;
1926
+ const log = opts.log ?? ((msg) => console.log(msg));
1927
+ const errLog = (msg) => {
1928
+ log(msg);
1929
+ logger.error(msg);
1930
+ };
1931
+ const aLog = logger.withAccount(accountId);
1932
+ log(`[weixin] monitor started (${baseUrl}, account=${accountId})`);
1933
+ aLog.info(`Monitor started: baseUrl=${baseUrl}`);
1934
+ const syncFilePath = getSyncBufFilePath(accountId);
1935
+ const previousGetUpdatesBuf = loadGetUpdatesBuf(syncFilePath);
1936
+ let getUpdatesBuf = previousGetUpdatesBuf ?? "";
1937
+ if (previousGetUpdatesBuf) log(`[weixin] resuming from previous sync buf (${getUpdatesBuf.length} bytes)`);
1938
+ else log(`[weixin] no previous sync buf, starting fresh`);
1939
+ const configManager = new WeixinConfigManager({
1940
+ baseUrl,
1941
+ token
1942
+ }, log);
1943
+ let nextTimeoutMs = longPollTimeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
1944
+ let consecutiveFailures = 0;
1945
+ while (!abortSignal?.aborted) try {
1946
+ const resp = await getUpdates({
1947
+ baseUrl,
1948
+ token,
1949
+ get_updates_buf: getUpdatesBuf,
1950
+ timeoutMs: nextTimeoutMs
1951
+ });
1952
+ if (resp.longpolling_timeout_ms != null && resp.longpolling_timeout_ms > 0) nextTimeoutMs = resp.longpolling_timeout_ms;
1953
+ if (resp.ret !== void 0 && resp.ret !== 0 || resp.errcode !== void 0 && resp.errcode !== 0) {
1954
+ if (resp.errcode === -14 || resp.ret === -14) {
1955
+ pauseSession(accountId);
1956
+ const pauseMs = getRemainingPauseMs(accountId);
1957
+ errLog(`[weixin] session expired (errcode -14), pausing for ${Math.ceil(pauseMs / 6e4)} min`);
1958
+ consecutiveFailures = 0;
1959
+ await sleep(pauseMs, abortSignal);
1960
+ continue;
1961
+ }
1962
+ consecutiveFailures += 1;
1963
+ errLog(`[weixin] getUpdates failed: ret=${resp.ret} errcode=${resp.errcode} errmsg=${resp.errmsg ?? ""} (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES})`);
1964
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
1965
+ errLog(`[weixin] ${MAX_CONSECUTIVE_FAILURES} consecutive failures, backing off 30s`);
1966
+ consecutiveFailures = 0;
1967
+ await sleep(BACKOFF_DELAY_MS, abortSignal);
1968
+ } else await sleep(RETRY_DELAY_MS, abortSignal);
1969
+ continue;
1970
+ }
1971
+ consecutiveFailures = 0;
1972
+ if (resp.get_updates_buf != null && resp.get_updates_buf !== "") {
1973
+ saveGetUpdatesBuf(syncFilePath, resp.get_updates_buf);
1974
+ getUpdatesBuf = resp.get_updates_buf;
1975
+ }
1976
+ const list = resp.msgs ?? [];
1977
+ for (const full of list) {
1978
+ aLog.info(`inbound: from=${full.from_user_id} types=${full.item_list?.map((i) => i.type).join(",") ?? "none"}`);
1979
+ const fromUserId = full.from_user_id ?? "";
1980
+ await processOneMessage(full, {
1981
+ accountId,
1982
+ agent,
1983
+ baseUrl,
1984
+ cdnBaseUrl,
1985
+ token,
1986
+ typingTicket: (await configManager.getForUser(fromUserId, full.context_token)).typingTicket,
1987
+ log,
1988
+ errLog
1989
+ });
1990
+ }
1991
+ } catch (err) {
1992
+ if (abortSignal?.aborted) {
1993
+ aLog.info(`Monitor stopped (aborted)`);
1994
+ return;
1995
+ }
1996
+ consecutiveFailures += 1;
1997
+ errLog(`[weixin] getUpdates error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${String(err)}`);
1998
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
1999
+ consecutiveFailures = 0;
2000
+ await sleep(BACKOFF_DELAY_MS, abortSignal);
2001
+ } else await sleep(RETRY_DELAY_MS, abortSignal);
2002
+ }
2003
+ aLog.info(`Monitor ended`);
2004
+ }
2005
+ function sleep(ms, signal) {
2006
+ return new Promise((resolve, reject) => {
2007
+ const t = setTimeout(resolve, ms);
2008
+ signal?.addEventListener("abort", () => {
2009
+ clearTimeout(t);
2010
+ reject(/* @__PURE__ */ new Error("aborted"));
2011
+ }, { once: true });
2012
+ });
2013
+ }
2014
+ //#endregion
2015
+ //#region src/bot.ts
2016
+ /**
2017
+ * Interactive QR-code login. Prints the QR code to the terminal and waits
2018
+ * for the user to scan it with WeChat.
2019
+ *
2020
+ * Returns the normalized account ID on success.
2021
+ */
2022
+ async function login(opts) {
2023
+ const log = opts?.log ?? console.log;
2024
+ const apiBaseUrl = opts?.baseUrl ?? "https://ilinkai.weixin.qq.com";
2025
+ log("正在启动微信扫码登录...");
2026
+ const startResult = await startWeixinLoginWithQr({
2027
+ apiBaseUrl,
2028
+ botType: "3"
2029
+ });
2030
+ if (!startResult.qrcodeUrl) throw new Error(startResult.message);
2031
+ log("\n使用微信扫描以下二维码,以完成连接:\n");
2032
+ try {
2033
+ const qrcodeterminal = await import("qrcode-terminal");
2034
+ await new Promise((resolve) => {
2035
+ qrcodeterminal.default.generate(startResult.qrcodeUrl, { small: true }, (qr) => {
2036
+ console.log(qr);
2037
+ resolve();
2038
+ });
2039
+ });
2040
+ } catch {
2041
+ log(`二维码链接: ${startResult.qrcodeUrl}`);
2042
+ }
2043
+ log("\n等待扫码...\n");
2044
+ const waitResult = await waitForWeixinLogin({
2045
+ sessionKey: startResult.sessionKey,
2046
+ apiBaseUrl,
2047
+ timeoutMs: 48e4,
2048
+ botType: "3"
2049
+ });
2050
+ if (!waitResult.connected || !waitResult.botToken || !waitResult.accountId) throw new Error(waitResult.message);
2051
+ const normalizedId = normalizeAccountId(waitResult.accountId);
2052
+ saveWeixinAccount(normalizedId, {
2053
+ token: waitResult.botToken,
2054
+ baseUrl: waitResult.baseUrl,
2055
+ userId: waitResult.userId
2056
+ });
2057
+ registerWeixinAccountId(normalizedId);
2058
+ log("\n✅ 与微信连接成功!");
2059
+ return normalizedId;
2060
+ }
2061
+ /**
2062
+ * Start the bot — long-polls for new messages and dispatches them to the agent.
2063
+ * Blocks until the abort signal fires or an unrecoverable error occurs.
2064
+ */
2065
+ async function start(agent, opts) {
2066
+ const log = opts?.log ?? console.log;
2067
+ let accountId = opts?.accountId;
2068
+ if (!accountId) {
2069
+ const ids = listWeixinAccountIds();
2070
+ if (ids.length === 0) throw new Error("没有已登录的账号,请先运行 login");
2071
+ accountId = ids[0];
2072
+ if (ids.length > 1) log(`[weixin] 检测到多个账号,使用第一个: ${accountId}`);
2073
+ }
2074
+ const account = resolveWeixinAccount(accountId);
2075
+ if (!account.configured) throw new Error(`账号 ${accountId} 未配置 (缺少 token),请先运行 login`);
2076
+ log(`[weixin] 启动 bot, account=${account.accountId}`);
2077
+ await monitorWeixinProvider({
2078
+ baseUrl: account.baseUrl,
2079
+ cdnBaseUrl: account.cdnBaseUrl,
2080
+ token: account.token,
2081
+ accountId: account.accountId,
2082
+ agent,
2083
+ abortSignal: opts?.abortSignal,
2084
+ log
2085
+ });
2086
+ }
2087
+ //#endregion
2088
+ export { login, start };