cli-wechat-bridge 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE.txt +21 -0
  2. package/README.md +637 -0
  3. package/bin/_run-entry.mjs +35 -0
  4. package/bin/wechat-bridge-claude.mjs +5 -0
  5. package/bin/wechat-bridge-codex.mjs +5 -0
  6. package/bin/wechat-bridge-opencode.mjs +5 -0
  7. package/bin/wechat-bridge-shell.mjs +5 -0
  8. package/bin/wechat-bridge.mjs +5 -0
  9. package/bin/wechat-check-update.mjs +5 -0
  10. package/bin/wechat-claude-start.mjs +5 -0
  11. package/bin/wechat-claude.mjs +5 -0
  12. package/bin/wechat-codex-start.mjs +5 -0
  13. package/bin/wechat-codex.mjs +5 -0
  14. package/bin/wechat-daemon.mjs +5 -0
  15. package/bin/wechat-opencode-start.mjs +5 -0
  16. package/bin/wechat-opencode.mjs +5 -0
  17. package/bin/wechat-setup.mjs +5 -0
  18. package/dist/bridge/bridge-adapter-common.js +95 -0
  19. package/dist/bridge/bridge-adapters.claude.js +829 -0
  20. package/dist/bridge/bridge-adapters.codex.js +2228 -0
  21. package/dist/bridge/bridge-adapters.core.js +717 -0
  22. package/dist/bridge/bridge-adapters.js +26 -0
  23. package/dist/bridge/bridge-adapters.opencode.js +2129 -0
  24. package/dist/bridge/bridge-adapters.shared.js +1005 -0
  25. package/dist/bridge/bridge-adapters.shell.js +363 -0
  26. package/dist/bridge/bridge-controller.js +48 -0
  27. package/dist/bridge/bridge-final-reply.js +46 -0
  28. package/dist/bridge/bridge-process-reaper.js +348 -0
  29. package/dist/bridge/bridge-state.js +362 -0
  30. package/dist/bridge/bridge-types.js +1 -0
  31. package/dist/bridge/bridge-utils.js +1240 -0
  32. package/dist/bridge/claude-hook.js +82 -0
  33. package/dist/bridge/claude-hooks.js +267 -0
  34. package/dist/bridge/wechat-bridge.js +1026 -0
  35. package/dist/commands/check-update.js +30 -0
  36. package/dist/companion/codex-panel-link.js +72 -0
  37. package/dist/companion/codex-panel.js +179 -0
  38. package/dist/companion/codex-remote-client.js +124 -0
  39. package/dist/companion/local-companion-link.js +240 -0
  40. package/dist/companion/local-companion-start.js +420 -0
  41. package/dist/companion/local-companion.js +424 -0
  42. package/dist/daemon/daemon-link.js +175 -0
  43. package/dist/daemon/wechat-daemon.js +1202 -0
  44. package/dist/media/media-types.js +1 -0
  45. package/dist/runtime/create-runtime-host.js +12 -0
  46. package/dist/runtime/legacy-adapter-runtime.js +46 -0
  47. package/dist/runtime/runtime-types.js +5 -0
  48. package/dist/utils/version-checker.js +161 -0
  49. package/dist/wechat/channel-config.js +196 -0
  50. package/dist/wechat/setup.js +283 -0
  51. package/dist/wechat/standalone-bot.js +355 -0
  52. package/dist/wechat/wechat-channel.js +492 -0
  53. package/dist/wechat/wechat-transport.js +1213 -0
  54. package/package.json +101 -0
@@ -0,0 +1,1213 @@
1
+ import crypto from "node:crypto";
2
+ import { createCipheriv, createDecipheriv } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { CONTEXT_CACHE_FILE, CREDENTIALS_FILE, ensureChannelDataDir, INBOUND_ATTACHMENTS_DIR, INBOUND_MESSAGE_CLAIMS_DIR, migrateLegacyChannelFiles, SYNC_BUF_FILE, } from "./channel-config.js";
6
+ export const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
7
+ const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
8
+ const CHANNEL_VERSION = "0.3.0";
9
+ const RECENT_MESSAGE_CACHE_SIZE = 500;
10
+ const BYTES_PER_MB = 1024 * 1024;
11
+ const SEND_TIMEOUT_MS = 15_000;
12
+ const INBOUND_DOWNLOAD_TIMEOUT_MS = 30_000;
13
+ const CDN_MAX_RETRIES = 3;
14
+ const ERROR_CAUSE_DEPTH_LIMIT = 4;
15
+ const INBOUND_MESSAGE_CLAIM_TTL_MS = 10 * 60 * 1000;
16
+ const SYNC_SESSION_TIMEOUT_ERRCODE = -14;
17
+ const MSG_TYPE_USER = 1;
18
+ const MSG_TYPE_BOT = 2;
19
+ const MSG_ITEM_TEXT = 1;
20
+ const MSG_ITEM_IMAGE = 2;
21
+ const MSG_ITEM_VOICE = 3;
22
+ const MSG_ITEM_FILE = 4;
23
+ const MSG_ITEM_VIDEO = 5;
24
+ const MSG_STATE_FINISH = 2;
25
+ const UPLOAD_MEDIA_TYPE_IMAGE = 1;
26
+ const UPLOAD_MEDIA_TYPE_VIDEO = 2;
27
+ const UPLOAD_MEDIA_TYPE_FILE = 3;
28
+ const UPLOAD_MEDIA_TYPE_VOICE = 4;
29
+ export function isWechatSyncSessionTimeout(response) {
30
+ return (response.errcode === SYNC_SESSION_TIMEOUT_ERRCODE &&
31
+ /session timeout/i.test(response.errmsg ?? ""));
32
+ }
33
+ export class WechatApiResponseError extends Error {
34
+ endpoint;
35
+ ret;
36
+ errcode;
37
+ errmsg;
38
+ constructor(params) {
39
+ const errmsg = params.errmsg ?? "";
40
+ super(`${params.endpoint} failed: ret=${params.ret} errcode=${params.errcode} errmsg=${errmsg}`);
41
+ this.name = "WechatApiResponseError";
42
+ this.endpoint = params.endpoint;
43
+ this.ret = params.ret;
44
+ this.errcode = params.errcode;
45
+ this.errmsg = errmsg;
46
+ }
47
+ }
48
+ export function isWechatContextTokenStaleError(error) {
49
+ return (error instanceof WechatApiResponseError &&
50
+ error.endpoint === "sendmessage" &&
51
+ error.ret === -2);
52
+ }
53
+ const DEFAULT_MEDIA_UPLOAD_LIMIT_MB = {
54
+ image: 20,
55
+ file: 50,
56
+ voice: 20,
57
+ video: 100,
58
+ };
59
+ const MEDIA_UPLOAD_LIMIT_ENV_KEYS = {
60
+ image: "WECHAT_MAX_IMAGE_MB",
61
+ file: "WECHAT_MAX_FILE_MB",
62
+ voice: "WECHAT_MAX_VOICE_MB",
63
+ video: "WECHAT_MAX_VIDEO_MB",
64
+ };
65
+ const DEFAULT_MEDIA_INBOUND_LIMIT_MB = {
66
+ image: 20,
67
+ file: 50,
68
+ };
69
+ const MEDIA_INBOUND_LIMIT_ENV_KEYS = {
70
+ image: "WECHAT_MAX_INBOUND_IMAGE_MB",
71
+ file: "WECHAT_MAX_INBOUND_FILE_MB",
72
+ };
73
+ const RETRYABLE_HTTP_STATUS_CODES = new Set([408, 425, 429]);
74
+ const RETRYABLE_NETWORK_ERROR_CODES = new Set([
75
+ "ECONNABORTED",
76
+ "ECONNREFUSED",
77
+ "ECONNRESET",
78
+ "EHOSTUNREACH",
79
+ "EPIPE",
80
+ "ETIMEDOUT",
81
+ "ENETUNREACH",
82
+ "ENOTFOUND",
83
+ "EAI_AGAIN",
84
+ "UND_ERR_CONNECT_TIMEOUT",
85
+ "UND_ERR_HEADERS_TIMEOUT",
86
+ "UND_ERR_SOCKET",
87
+ ]);
88
+ const RETRYABLE_NETWORK_ERROR_HINTS = [
89
+ "connection closed",
90
+ "connection reset",
91
+ "connection refused",
92
+ "econnaborted",
93
+ "econnrefused",
94
+ "econnreset",
95
+ "ehostunreach",
96
+ "enetunreach",
97
+ "enotfound",
98
+ "eai_again",
99
+ "fetch failed",
100
+ "network error",
101
+ "request timeout",
102
+ "socket hang up",
103
+ "timed out",
104
+ "timeout",
105
+ ];
106
+ function readJsonFile(filePath) {
107
+ try {
108
+ if (!fs.existsSync(filePath)) {
109
+ return null;
110
+ }
111
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ function isRecord(value) {
118
+ return typeof value === "object" && value !== null;
119
+ }
120
+ function readNumericResponseField(response, key) {
121
+ const value = response[key];
122
+ if (typeof value === "number" && Number.isFinite(value)) {
123
+ return value;
124
+ }
125
+ if (typeof value === "string" && value.trim()) {
126
+ const parsed = Number(value);
127
+ return Number.isFinite(parsed) ? parsed : undefined;
128
+ }
129
+ return undefined;
130
+ }
131
+ function readStringResponseField(response, key) {
132
+ const value = response[key];
133
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
134
+ }
135
+ export function assertWechatApiResponseOk(endpoint, raw) {
136
+ const trimmed = raw.trim();
137
+ if (!trimmed) {
138
+ return;
139
+ }
140
+ let response;
141
+ try {
142
+ response = JSON.parse(trimmed);
143
+ }
144
+ catch {
145
+ return;
146
+ }
147
+ if (!isRecord(response)) {
148
+ return;
149
+ }
150
+ const ret = readNumericResponseField(response, "ret");
151
+ const errcode = readNumericResponseField(response, "errcode");
152
+ const failed = (ret !== undefined && ret !== 0) ||
153
+ (errcode !== undefined && errcode !== 0);
154
+ if (!failed) {
155
+ return;
156
+ }
157
+ const errmsg = readStringResponseField(response, "errmsg") ??
158
+ readStringResponseField(response, "message") ??
159
+ readStringResponseField(response, "msg") ??
160
+ "";
161
+ throw new WechatApiResponseError({ endpoint, ret, errcode, errmsg });
162
+ }
163
+ function describeErrorNode(value) {
164
+ if (value instanceof Error) {
165
+ const error = value;
166
+ const parts = [];
167
+ if (error.name && error.message) {
168
+ parts.push(`${error.name}: ${error.message}`);
169
+ }
170
+ else if (error.message) {
171
+ parts.push(error.message);
172
+ }
173
+ else if (error.name) {
174
+ parts.push(error.name);
175
+ }
176
+ if (typeof error.code === "string" && error.code.trim()) {
177
+ parts.push(`code=${error.code}`);
178
+ }
179
+ if ((typeof error.errno === "string" && error.errno.trim()) ||
180
+ typeof error.errno === "number") {
181
+ parts.push(`errno=${error.errno}`);
182
+ }
183
+ if (typeof error.syscall === "string" && error.syscall.trim()) {
184
+ parts.push(`syscall=${error.syscall}`);
185
+ }
186
+ if (typeof error.hostname === "string" && error.hostname.trim()) {
187
+ parts.push(`host=${error.hostname}`);
188
+ }
189
+ if (typeof error.address === "string" && error.address.trim()) {
190
+ parts.push(`address=${error.address}`);
191
+ }
192
+ if ((typeof error.port === "string" && error.port.trim()) ||
193
+ typeof error.port === "number") {
194
+ parts.push(`port=${error.port}`);
195
+ }
196
+ return parts.filter(Boolean).join(" ");
197
+ }
198
+ if (isRecord(value)) {
199
+ const parts = [];
200
+ if (typeof value.message === "string" && value.message.trim()) {
201
+ parts.push(value.message);
202
+ }
203
+ if (typeof value.code === "string" && value.code.trim()) {
204
+ parts.push(`code=${value.code}`);
205
+ }
206
+ if ((typeof value.errno === "string" && value.errno.trim()) ||
207
+ typeof value.errno === "number") {
208
+ parts.push(`errno=${value.errno}`);
209
+ }
210
+ if (typeof value.syscall === "string" && value.syscall.trim()) {
211
+ parts.push(`syscall=${value.syscall}`);
212
+ }
213
+ if (typeof value.hostname === "string" && value.hostname.trim()) {
214
+ parts.push(`host=${value.hostname}`);
215
+ }
216
+ if (typeof value.address === "string" && value.address.trim()) {
217
+ parts.push(`address=${value.address}`);
218
+ }
219
+ if ((typeof value.port === "string" && value.port.trim()) ||
220
+ typeof value.port === "number") {
221
+ parts.push(`port=${value.port}`);
222
+ }
223
+ if (parts.length > 0) {
224
+ return parts.join(" ");
225
+ }
226
+ }
227
+ return String(value);
228
+ }
229
+ function getErrorCause(value) {
230
+ if (value instanceof Error) {
231
+ return value.cause;
232
+ }
233
+ if (isRecord(value) && "cause" in value) {
234
+ return value.cause;
235
+ }
236
+ return undefined;
237
+ }
238
+ function collectErrorCodes(value) {
239
+ const seen = new Set();
240
+ const codes = new Set();
241
+ let current = value;
242
+ let depth = 0;
243
+ while (current && depth < ERROR_CAUSE_DEPTH_LIMIT && !seen.has(current)) {
244
+ seen.add(current);
245
+ if (isRecord(current) && typeof current.code === "string" && current.code.trim()) {
246
+ codes.add(current.code.toUpperCase());
247
+ }
248
+ current = getErrorCause(current);
249
+ depth += 1;
250
+ }
251
+ return [...codes];
252
+ }
253
+ function extractHttpStatusCode(error) {
254
+ const match = /^HTTP (\d{3}):/.exec(error.message);
255
+ if (!match) {
256
+ return null;
257
+ }
258
+ const parsed = Number(match[1]);
259
+ return Number.isFinite(parsed) ? parsed : null;
260
+ }
261
+ export function describeWechatTransportError(error) {
262
+ const seen = new Set();
263
+ const parts = [];
264
+ let current = error;
265
+ let depth = 0;
266
+ while (current && depth < ERROR_CAUSE_DEPTH_LIMIT && !seen.has(current)) {
267
+ seen.add(current);
268
+ const description = describeErrorNode(current);
269
+ if (description) {
270
+ parts.push(depth === 0 ? description : `cause: ${description}`);
271
+ }
272
+ current = getErrorCause(current);
273
+ depth += 1;
274
+ }
275
+ return parts.length > 0 ? parts.join(" | ") : String(error);
276
+ }
277
+ export function classifyWechatTransportError(error) {
278
+ if (error instanceof Error) {
279
+ if (/WeChat session timed out/i.test(error.message) ||
280
+ /errcode=-14\b.*session timeout/i.test(error.message)) {
281
+ return { kind: "auth", retryable: false };
282
+ }
283
+ if (error.name === "AbortError") {
284
+ return { kind: "timeout", retryable: true };
285
+ }
286
+ const statusCode = extractHttpStatusCode(error);
287
+ if (statusCode !== null) {
288
+ if (statusCode === 401 || statusCode === 403) {
289
+ return { kind: "auth", retryable: false, statusCode };
290
+ }
291
+ if (statusCode >= 500 || RETRYABLE_HTTP_STATUS_CODES.has(statusCode)) {
292
+ return { kind: "http", retryable: true, statusCode };
293
+ }
294
+ return { kind: "http", retryable: false, statusCode };
295
+ }
296
+ }
297
+ const errorCodes = collectErrorCodes(error);
298
+ if (errorCodes.some((code) => RETRYABLE_NETWORK_ERROR_CODES.has(code))) {
299
+ return { kind: "network", retryable: true };
300
+ }
301
+ const details = describeWechatTransportError(error).toLowerCase();
302
+ if (RETRYABLE_NETWORK_ERROR_HINTS.some((hint) => details.includes(hint))) {
303
+ return { kind: "network", retryable: true };
304
+ }
305
+ return { kind: "unknown", retryable: false };
306
+ }
307
+ function writeJsonFile(filePath, value) {
308
+ ensureChannelDataDir();
309
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
310
+ }
311
+ function randomWechatUin() {
312
+ const uint32 = crypto.randomBytes(4).readUInt32BE(0);
313
+ return Buffer.from(String(uint32), "utf-8").toString("base64");
314
+ }
315
+ function buildHeaders(token, body) {
316
+ const headers = {
317
+ "Content-Type": "application/json",
318
+ AuthorizationType: "ilink_bot_token",
319
+ "X-WECHAT-UIN": randomWechatUin(),
320
+ };
321
+ if (body) {
322
+ headers["Content-Length"] = String(Buffer.byteLength(body, "utf-8"));
323
+ }
324
+ if (token?.trim()) {
325
+ headers.Authorization = `Bearer ${token.trim()}`;
326
+ }
327
+ return headers;
328
+ }
329
+ async function apiFetch(params) {
330
+ const base = params.baseUrl.endsWith("/") ? params.baseUrl : `${params.baseUrl}/`;
331
+ const url = new URL(params.endpoint, base).toString();
332
+ const controller = new AbortController();
333
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs);
334
+ try {
335
+ const res = await fetch(url, {
336
+ method: "POST",
337
+ headers: buildHeaders(params.token, params.body),
338
+ body: params.body,
339
+ signal: controller.signal,
340
+ });
341
+ clearTimeout(timer);
342
+ const text = await res.text();
343
+ if (!res.ok) {
344
+ throw new Error(`HTTP ${res.status}: ${text}`);
345
+ }
346
+ return text;
347
+ }
348
+ catch (err) {
349
+ clearTimeout(timer);
350
+ throw err;
351
+ }
352
+ }
353
+ function encryptAesEcb(plaintext, key) {
354
+ const cipher = createCipheriv("aes-128-ecb", key, null);
355
+ return Buffer.concat([cipher.update(plaintext), cipher.final()]);
356
+ }
357
+ function decryptAesEcb(ciphertext, key) {
358
+ const decipher = createDecipheriv("aes-128-ecb", key, null);
359
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
360
+ }
361
+ function aesEcbPaddedSize(plaintextSize) {
362
+ return Math.ceil((plaintextSize + 1) / 16) * 16;
363
+ }
364
+ function isHexAesKey(value) {
365
+ return /^[a-f0-9]{32}$/i.test(value);
366
+ }
367
+ export function decodeInboundMediaAesKey(value) {
368
+ const trimmed = value.trim();
369
+ if (isHexAesKey(trimmed)) {
370
+ return Buffer.from(trimmed, "hex");
371
+ }
372
+ const decoded = Buffer.from(trimmed, "base64");
373
+ if (decoded.length === 16) {
374
+ return decoded;
375
+ }
376
+ const decodedText = decoded.toString("utf8").trim();
377
+ if (isHexAesKey(decodedText)) {
378
+ return Buffer.from(decodedText, "hex");
379
+ }
380
+ throw new Error("Unsupported inbound media aes key format.");
381
+ }
382
+ export function decryptInboundMediaPayload(ciphertext, aesKey) {
383
+ return decryptAesEcb(ciphertext, decodeInboundMediaAesKey(aesKey));
384
+ }
385
+ export function formatByteSize(bytes) {
386
+ if (!Number.isFinite(bytes) || bytes < 0) {
387
+ return "0 B";
388
+ }
389
+ if (bytes >= BYTES_PER_MB) {
390
+ const value = bytes / BYTES_PER_MB;
391
+ return `${value.toFixed(value >= 100 ? 0 : 1)} MB`;
392
+ }
393
+ if (bytes >= 1024) {
394
+ const value = bytes / 1024;
395
+ return `${value.toFixed(value >= 100 ? 0 : 1)} KB`;
396
+ }
397
+ return `${bytes} B`;
398
+ }
399
+ export function resolveMediaUploadLimitBytes(label, env = process.env) {
400
+ const envKey = MEDIA_UPLOAD_LIMIT_ENV_KEYS[label];
401
+ const raw = env[envKey];
402
+ const fallbackMb = DEFAULT_MEDIA_UPLOAD_LIMIT_MB[label];
403
+ const parsedMb = raw ? Number(raw) : Number.NaN;
404
+ const limitMb = Number.isFinite(parsedMb) && parsedMb > 0 ? parsedMb : fallbackMb;
405
+ return Math.floor(limitMb * BYTES_PER_MB);
406
+ }
407
+ export function resolveInboundMediaDownloadLimitBytes(label, env = process.env) {
408
+ const envKey = MEDIA_INBOUND_LIMIT_ENV_KEYS[label];
409
+ const raw = env[envKey];
410
+ const fallbackMb = DEFAULT_MEDIA_INBOUND_LIMIT_MB[label];
411
+ const parsedMb = raw ? Number(raw) : Number.NaN;
412
+ const limitMb = Number.isFinite(parsedMb) && parsedMb > 0 ? parsedMb : fallbackMb;
413
+ return Math.floor(limitMb * BYTES_PER_MB);
414
+ }
415
+ export function assertMediaUploadSizeAllowed(label, rawsize, env = process.env) {
416
+ const limitBytes = resolveMediaUploadLimitBytes(label, env);
417
+ if (rawsize <= limitBytes) {
418
+ return;
419
+ }
420
+ const envKey = MEDIA_UPLOAD_LIMIT_ENV_KEYS[label];
421
+ const labelName = label.charAt(0).toUpperCase() + label.slice(1);
422
+ throw new Error(`${labelName} too large: ${formatByteSize(rawsize)} exceeds ${formatByteSize(limitBytes)} limit. Set ${envKey} to override.`);
423
+ }
424
+ function assertInboundMediaDownloadSizeAllowed(label, rawsize, env = process.env) {
425
+ const limitBytes = resolveInboundMediaDownloadLimitBytes(label, env);
426
+ if (rawsize <= limitBytes) {
427
+ return;
428
+ }
429
+ const envKey = MEDIA_INBOUND_LIMIT_ENV_KEYS[label];
430
+ const labelName = label.charAt(0).toUpperCase() + label.slice(1);
431
+ throw new Error(`${labelName} too large: ${formatByteSize(rawsize)} exceeds ${formatByteSize(limitBytes)} inbound limit. Set ${envKey} to override.`);
432
+ }
433
+ function encodeMessageAesKey(aeskey) {
434
+ return Buffer.from(aeskey.toString("hex")).toString("base64");
435
+ }
436
+ async function getUploadUrl(account, params) {
437
+ const raw = await apiFetch({
438
+ baseUrl: account.baseUrl,
439
+ endpoint: "ilink/bot/getuploadurl",
440
+ body: JSON.stringify({
441
+ ...params,
442
+ no_need_thumb: true,
443
+ base_info: { channel_version: CHANNEL_VERSION },
444
+ }),
445
+ token: account.token,
446
+ timeoutMs: SEND_TIMEOUT_MS,
447
+ });
448
+ return JSON.parse(raw);
449
+ }
450
+ function buildCdnUploadUrl(cdnBaseUrl, uploadParam, filekey) {
451
+ return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
452
+ }
453
+ export function buildCdnDownloadUrl(cdnBaseUrl, downloadParam) {
454
+ return `${cdnBaseUrl}/download?encrypted_query_param=${encodeURIComponent(downloadParam)}`;
455
+ }
456
+ function isTrustedCdnDownloadUrl(url) {
457
+ const trustedHost = new URL(CDN_BASE_URL).hostname;
458
+ return url.protocol === "https:" && url.hostname === trustedHost;
459
+ }
460
+ function resolveCdnDownloadUrl(media) {
461
+ const fullUrl = media.full_url?.trim();
462
+ if (fullUrl) {
463
+ try {
464
+ const url = new URL(fullUrl);
465
+ if (isTrustedCdnDownloadUrl(url)) {
466
+ return url.toString();
467
+ }
468
+ }
469
+ catch {
470
+ // Fall back to the encrypted query param below.
471
+ }
472
+ }
473
+ const downloadParam = media.encrypt_query_param?.trim();
474
+ if (!downloadParam) {
475
+ throw new Error("Inbound media is missing encrypt_query_param.");
476
+ }
477
+ return buildCdnDownloadUrl(CDN_BASE_URL, downloadParam);
478
+ }
479
+ async function uploadBufferToCdn(params) {
480
+ const ciphertext = encryptAesEcb(params.buf, params.aeskey);
481
+ const cdnUrl = buildCdnUploadUrl(CDN_BASE_URL, params.uploadParam, params.filekey);
482
+ let downloadParam;
483
+ let lastError;
484
+ for (let attempt = 1; attempt <= CDN_MAX_RETRIES; attempt += 1) {
485
+ try {
486
+ const res = await fetch(cdnUrl, {
487
+ method: "POST",
488
+ headers: { "Content-Type": "application/octet-stream" },
489
+ body: new Uint8Array(ciphertext),
490
+ });
491
+ if (res.status >= 400 && res.status < 500) {
492
+ const errMsg = res.headers.get("x-error-message") ?? (await res.text());
493
+ throw new Error(`CDN client error ${res.status}: ${errMsg}`);
494
+ }
495
+ if (res.status !== 200) {
496
+ const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
497
+ throw new Error(`CDN server error: ${errMsg}`);
498
+ }
499
+ downloadParam = res.headers.get("x-encrypted-param") ?? undefined;
500
+ if (!downloadParam) {
501
+ throw new Error("CDN response missing x-encrypted-param header");
502
+ }
503
+ break;
504
+ }
505
+ catch (err) {
506
+ lastError = err;
507
+ if (err instanceof Error && err.message.includes("client error")) {
508
+ throw err;
509
+ }
510
+ if (attempt >= CDN_MAX_RETRIES) {
511
+ break;
512
+ }
513
+ params.onRetry?.(attempt);
514
+ }
515
+ }
516
+ if (!downloadParam) {
517
+ throw lastError instanceof Error ? lastError : new Error("CDN upload failed");
518
+ }
519
+ return { downloadParam };
520
+ }
521
+ async function downloadBufferFromCdn(params) {
522
+ const cdnUrl = resolveCdnDownloadUrl(params.media);
523
+ const limitBytes = resolveInboundMediaDownloadLimitBytes(params.kind);
524
+ let lastError;
525
+ for (let attempt = 1; attempt <= CDN_MAX_RETRIES; attempt += 1) {
526
+ const controller = new AbortController();
527
+ const timer = setTimeout(() => controller.abort(), INBOUND_DOWNLOAD_TIMEOUT_MS);
528
+ try {
529
+ const res = await fetch(cdnUrl, {
530
+ method: "GET",
531
+ signal: controller.signal,
532
+ });
533
+ clearTimeout(timer);
534
+ if (res.status >= 400 && res.status < 500) {
535
+ const errMsg = res.headers.get("x-error-message") ?? (await res.text());
536
+ throw new Error(`CDN client error ${res.status}: ${errMsg}`);
537
+ }
538
+ if (res.status !== 200) {
539
+ const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
540
+ throw new Error(`CDN server error: ${errMsg}`);
541
+ }
542
+ const contentLength = Number(res.headers.get("content-length") ?? "");
543
+ if (Number.isFinite(contentLength) && contentLength > aesEcbPaddedSize(limitBytes)) {
544
+ throw new Error(`${params.kind} download too large: ${formatByteSize(contentLength)} encrypted payload exceeds ${formatByteSize(limitBytes)} inbound limit.`);
545
+ }
546
+ return Buffer.from(await res.arrayBuffer());
547
+ }
548
+ catch (err) {
549
+ clearTimeout(timer);
550
+ lastError = err;
551
+ if (err instanceof Error && err.message.includes("client error")) {
552
+ throw err;
553
+ }
554
+ if (attempt >= CDN_MAX_RETRIES) {
555
+ break;
556
+ }
557
+ params.onRetry?.(attempt);
558
+ }
559
+ }
560
+ throw lastError instanceof Error ? lastError : new Error("CDN download failed");
561
+ }
562
+ function extractReferenceLabel(item) {
563
+ const ref = item.ref_msg;
564
+ if (!ref) {
565
+ return null;
566
+ }
567
+ const parts = [];
568
+ if (ref.title?.trim()) {
569
+ parts.push(ref.title.trim());
570
+ }
571
+ const quotedText = ref.message_item?.text_item?.text?.trim();
572
+ if (quotedText) {
573
+ parts.push(quotedText);
574
+ }
575
+ return parts.length ? `Quoted: ${parts.join(" | ")}` : null;
576
+ }
577
+ function parseExpectedSize(value) {
578
+ const parsed = typeof value === "number"
579
+ ? value
580
+ : typeof value === "string"
581
+ ? Number(value)
582
+ : Number.NaN;
583
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
584
+ }
585
+ function buildInboundAttachmentDescriptor(kind, item) {
586
+ if (kind === "image") {
587
+ const imageItem = item.image_item;
588
+ const media = imageItem?.media;
589
+ const aesKey = media?.aes_key ?? media?.aeskey ?? imageItem?.aes_key ?? imageItem?.aeskey;
590
+ if (!media || !aesKey?.trim()) {
591
+ return null;
592
+ }
593
+ return {
594
+ kind,
595
+ fileName: imageItem?.file_name?.trim() || "wechat-image.jpg",
596
+ media,
597
+ aesKey,
598
+ expectedSizeBytes: parseExpectedSize(imageItem?.mid_size),
599
+ };
600
+ }
601
+ const fileItem = item.file_item;
602
+ const media = fileItem?.media;
603
+ const aesKey = media?.aes_key ?? media?.aeskey ?? fileItem?.aes_key ?? fileItem?.aeskey;
604
+ if (!media || !aesKey?.trim()) {
605
+ return null;
606
+ }
607
+ return {
608
+ kind,
609
+ fileName: fileItem?.file_name?.trim() || "wechat-file",
610
+ media,
611
+ aesKey,
612
+ expectedSizeBytes: parseExpectedSize(fileItem?.len),
613
+ };
614
+ }
615
+ function formatUnsupportedInboundAttachment(kind) {
616
+ return `[WeChat ${kind} attachment could not be downloaded: missing media metadata]`;
617
+ }
618
+ export function extractInboundMessageContent(message) {
619
+ if (!message.item_list?.length) {
620
+ return { text: "", attachments: [] };
621
+ }
622
+ const lines = [];
623
+ const attachments = [];
624
+ for (const item of message.item_list) {
625
+ const reference = extractReferenceLabel(item);
626
+ if (reference && !lines.includes(reference)) {
627
+ lines.push(reference);
628
+ }
629
+ if (item.type === MSG_ITEM_TEXT) {
630
+ const text = item.text_item?.text?.trim();
631
+ if (text) {
632
+ lines.push(text);
633
+ }
634
+ }
635
+ if (item.type === MSG_ITEM_VOICE) {
636
+ const transcript = item.voice_item?.text?.trim();
637
+ if (transcript) {
638
+ lines.push(transcript);
639
+ }
640
+ }
641
+ if (item.type === MSG_ITEM_IMAGE) {
642
+ const attachment = buildInboundAttachmentDescriptor("image", item);
643
+ if (attachment) {
644
+ attachments.push(attachment);
645
+ }
646
+ else {
647
+ lines.push(formatUnsupportedInboundAttachment("image"));
648
+ }
649
+ }
650
+ if (item.type === MSG_ITEM_FILE) {
651
+ const attachment = buildInboundAttachmentDescriptor("file", item);
652
+ if (attachment) {
653
+ attachments.push(attachment);
654
+ }
655
+ else {
656
+ lines.push(formatUnsupportedInboundAttachment("file"));
657
+ }
658
+ }
659
+ }
660
+ return {
661
+ text: lines.join("\n").trim(),
662
+ attachments,
663
+ };
664
+ }
665
+ function buildMessageKey(message) {
666
+ return [
667
+ message.from_user_id ?? "",
668
+ message.client_id ?? "",
669
+ String(message.create_time_ms ?? ""),
670
+ message.context_token ?? "",
671
+ ].join("|");
672
+ }
673
+ function buildScopedMessageClaimKey(accountId, messageKey) {
674
+ return `${accountId}|${messageKey}`;
675
+ }
676
+ export function buildInboundMessageClaimPath(messageKey, claimsDir = INBOUND_MESSAGE_CLAIMS_DIR) {
677
+ const fileName = `${crypto.createHash("sha1").update(messageKey).digest("hex")}.json`;
678
+ return path.join(claimsDir, fileName);
679
+ }
680
+ export function clearInboundMessageClaims(claimsDir = INBOUND_MESSAGE_CLAIMS_DIR) {
681
+ try {
682
+ fs.rmSync(claimsDir, { recursive: true, force: true });
683
+ }
684
+ catch {
685
+ // Best effort cleanup.
686
+ }
687
+ }
688
+ export function tryClaimInboundMessage(messageKey, options = {}) {
689
+ if (!messageKey) {
690
+ return false;
691
+ }
692
+ const claimsDir = options.claimsDir ?? INBOUND_MESSAGE_CLAIMS_DIR;
693
+ const nowMs = options.nowMs ?? Date.now();
694
+ const ttlMs = options.ttlMs ?? INBOUND_MESSAGE_CLAIM_TTL_MS;
695
+ const claimPath = buildInboundMessageClaimPath(messageKey, claimsDir);
696
+ const attemptClaim = () => {
697
+ fs.mkdirSync(claimsDir, { recursive: true });
698
+ const handle = fs.openSync(claimPath, "wx");
699
+ try {
700
+ fs.writeFileSync(handle, JSON.stringify({
701
+ key: messageKey,
702
+ claimedAt: new Date(nowMs).toISOString(),
703
+ pid: process.pid,
704
+ }, null, 2), "utf-8");
705
+ }
706
+ finally {
707
+ fs.closeSync(handle);
708
+ }
709
+ return true;
710
+ };
711
+ try {
712
+ return attemptClaim();
713
+ }
714
+ catch (error) {
715
+ const code = typeof error === "object" &&
716
+ error !== null &&
717
+ "code" in error &&
718
+ typeof error.code === "string"
719
+ ? error.code
720
+ : "";
721
+ if (code !== "EEXIST") {
722
+ return true;
723
+ }
724
+ }
725
+ try {
726
+ const stat = fs.statSync(claimPath);
727
+ if (Number.isFinite(stat.mtimeMs) && nowMs - stat.mtimeMs > ttlMs) {
728
+ fs.rmSync(claimPath, { force: true });
729
+ return attemptClaim();
730
+ }
731
+ }
732
+ catch {
733
+ return attemptClaim();
734
+ }
735
+ return false;
736
+ }
737
+ function normalizeSender(senderId) {
738
+ return senderId.split("@")[0] || senderId;
739
+ }
740
+ function formatTimestamp(timestampMs) {
741
+ if (!timestampMs) {
742
+ return new Date().toISOString();
743
+ }
744
+ return new Date(timestampMs).toISOString();
745
+ }
746
+ function appendInboundAttachmentFailureText(text, failureLines) {
747
+ if (failureLines.length === 0) {
748
+ return text;
749
+ }
750
+ return [text, ...failureLines].filter(Boolean).join("\n").trim();
751
+ }
752
+ function sanitizeInboundAttachmentFileName(fileName, fallback) {
753
+ const lastSegment = fileName.trim().replace(/\\/g, "/").split("/").pop() ?? "";
754
+ const withoutControlChars = Array.from(lastSegment)
755
+ .map((character) => (character.charCodeAt(0) < 32 ? "_" : character))
756
+ .join("");
757
+ const sanitized = withoutControlChars
758
+ .replace(/[<>:"/\\|?*]+/g, "_")
759
+ .replace(/\s+/g, " ")
760
+ .trim();
761
+ return sanitized.slice(0, 160) || fallback;
762
+ }
763
+ function buildInboundAttachmentFilePath(params) {
764
+ const timestamp = formatTimestamp(params.createdAtMs);
765
+ const day = timestamp.slice(0, 10);
766
+ const directory = path.join(INBOUND_ATTACHMENTS_DIR, day);
767
+ const fallback = params.kind === "image" ? "wechat-image.jpg" : "wechat-file";
768
+ const safeFileName = sanitizeInboundAttachmentFileName(params.fileName, fallback);
769
+ const uniquePrefix = `${timestamp.replace(/[:.]/g, "-")}-${crypto.randomBytes(4).toString("hex")}`;
770
+ return path.join(directory, `${uniquePrefix}-${safeFileName}`);
771
+ }
772
+ export class WeChatTransport {
773
+ logger;
774
+ recentMessageKeys = new Set();
775
+ recentMessageOrder = [];
776
+ contextTokenCache;
777
+ syncBuffer = "";
778
+ constructor(logger) {
779
+ this.logger = logger;
780
+ migrateLegacyChannelFiles((message) => this.logger.log(message));
781
+ this.contextTokenCache = new Map(Object.entries(readJsonFile(CONTEXT_CACHE_FILE) ?? {}));
782
+ this.syncBuffer = this.readSyncBuffer();
783
+ }
784
+ getCredentials() {
785
+ return readJsonFile(CREDENTIALS_FILE);
786
+ }
787
+ getStatusText() {
788
+ const account = this.getCredentials();
789
+ const syncExists = fs.existsSync(SYNC_BUF_FILE);
790
+ const contextExists = fs.existsSync(CONTEXT_CACHE_FILE);
791
+ return [
792
+ `credentials_file: ${CREDENTIALS_FILE}`,
793
+ `credentials_present: ${account ? "yes" : "no"}`,
794
+ `sync_state_file: ${SYNC_BUF_FILE}`,
795
+ `sync_state_present: ${syncExists ? "yes" : "no"}`,
796
+ `context_cache_file: ${CONTEXT_CACHE_FILE}`,
797
+ `context_cache_present: ${contextExists ? "yes" : "no"}`,
798
+ `cached_context_count: ${this.contextTokenCache.size}`,
799
+ `max_image_mb: ${resolveMediaUploadLimitBytes("image") / BYTES_PER_MB}`,
800
+ `max_file_mb: ${resolveMediaUploadLimitBytes("file") / BYTES_PER_MB}`,
801
+ `max_voice_mb: ${resolveMediaUploadLimitBytes("voice") / BYTES_PER_MB}`,
802
+ `max_video_mb: ${resolveMediaUploadLimitBytes("video") / BYTES_PER_MB}`,
803
+ `max_inbound_image_mb: ${resolveInboundMediaDownloadLimitBytes("image") / BYTES_PER_MB}`,
804
+ `max_inbound_file_mb: ${resolveInboundMediaDownloadLimitBytes("file") / BYTES_PER_MB}`,
805
+ `account_id: ${account?.accountId ?? "(none)"}`,
806
+ `user_id: ${account?.userId ?? "(none)"}`,
807
+ `saved_at: ${account?.savedAt ?? "(none)"}`,
808
+ ].join("\n");
809
+ }
810
+ resetSyncState(options = {}) {
811
+ this.clearSyncBuffer();
812
+ this.clearRecentMessages();
813
+ clearInboundMessageClaims();
814
+ if (options.clearContextCache) {
815
+ this.clearContextTokenCache();
816
+ }
817
+ return options.clearContextCache
818
+ ? "Reset sync state and cleared cached context tokens."
819
+ : "Reset sync state.";
820
+ }
821
+ async pollMessages(options = {}) {
822
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
823
+ const account = this.requireAccount();
824
+ let response = await this.getUpdates(account, timeoutMs);
825
+ if (isWechatSyncSessionTimeout(response) && this.syncBuffer) {
826
+ this.logger.log("WeChat sync session timed out. Clearing local sync cursor and retrying once.");
827
+ this.clearSyncBuffer();
828
+ response = await this.getUpdates(account, timeoutMs);
829
+ }
830
+ if (isWechatSyncSessionTimeout(response)) {
831
+ throw new Error('WeChat session timed out. Run "wechat-setup" to log in again.');
832
+ }
833
+ const isError = (response.ret !== undefined && response.ret !== 0) ||
834
+ (response.errcode !== undefined && response.errcode !== 0);
835
+ if (isError) {
836
+ throw new Error(`getUpdates failed: ret=${response.ret} errcode=${response.errcode} errmsg=${response.errmsg ?? ""}`);
837
+ }
838
+ if (response.get_updates_buf) {
839
+ this.syncBuffer = response.get_updates_buf;
840
+ this.saveSyncBuffer(this.syncBuffer);
841
+ }
842
+ const messages = [];
843
+ let ignoredBacklogCount = 0;
844
+ for (const rawMessage of response.msgs ?? []) {
845
+ if (rawMessage.message_type !== MSG_TYPE_USER) {
846
+ continue;
847
+ }
848
+ const extracted = extractInboundMessageContent(rawMessage);
849
+ if (!extracted.text && extracted.attachments.length === 0) {
850
+ continue;
851
+ }
852
+ const messageKey = buildMessageKey(rawMessage);
853
+ if (!this.rememberMessage(messageKey)) {
854
+ continue;
855
+ }
856
+ if (!tryClaimInboundMessage(buildScopedMessageClaimKey(account.accountId, messageKey))) {
857
+ continue;
858
+ }
859
+ const senderId = rawMessage.from_user_id ?? "unknown";
860
+ if (rawMessage.context_token) {
861
+ this.cacheContextToken(senderId, rawMessage.context_token);
862
+ }
863
+ const createdAtMs = rawMessage.create_time_ms ?? 0;
864
+ if (typeof options.minCreatedAtMs === "number" &&
865
+ (!Number.isFinite(createdAtMs) || createdAtMs < options.minCreatedAtMs)) {
866
+ ignoredBacklogCount += 1;
867
+ continue;
868
+ }
869
+ const { attachments, failureLines } = await this.downloadInboundAttachments(extracted.attachments, rawMessage);
870
+ const text = appendInboundAttachmentFailureText(extracted.text, failureLines);
871
+ messages.push({
872
+ senderId,
873
+ sender: normalizeSender(senderId),
874
+ sessionId: rawMessage.session_id ?? "",
875
+ text,
876
+ attachments,
877
+ contextToken: rawMessage.context_token,
878
+ createdAt: formatTimestamp(rawMessage.create_time_ms),
879
+ createdAtMs,
880
+ });
881
+ }
882
+ return { messages, ignoredBacklogCount };
883
+ }
884
+ async downloadInboundAttachments(descriptors, rawMessage) {
885
+ const attachments = [];
886
+ const failureLines = [];
887
+ for (const descriptor of descriptors) {
888
+ try {
889
+ const encrypted = await downloadBufferFromCdn({
890
+ media: descriptor.media,
891
+ kind: descriptor.kind,
892
+ onRetry: (attempt) => {
893
+ this.logger.log(`CDN download attempt ${attempt} failed for inbound ${descriptor.kind}, retrying...`);
894
+ },
895
+ });
896
+ const plaintext = decryptInboundMediaPayload(encrypted, descriptor.aesKey);
897
+ assertInboundMediaDownloadSizeAllowed(descriptor.kind, plaintext.length);
898
+ if (descriptor.expectedSizeBytes !== undefined &&
899
+ plaintext.length !== descriptor.expectedSizeBytes) {
900
+ this.logger.log(`Inbound ${descriptor.kind} size differs from metadata: expected=${descriptor.expectedSizeBytes} actual=${plaintext.length}`);
901
+ }
902
+ const filePath = buildInboundAttachmentFilePath({
903
+ kind: descriptor.kind,
904
+ fileName: descriptor.fileName,
905
+ createdAtMs: rawMessage.create_time_ms,
906
+ });
907
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
908
+ fs.writeFileSync(filePath, plaintext);
909
+ attachments.push({
910
+ kind: descriptor.kind,
911
+ path: filePath,
912
+ fileName: path.basename(filePath),
913
+ sizeBytes: plaintext.length,
914
+ });
915
+ }
916
+ catch (error) {
917
+ const message = `Failed to download inbound WeChat ${descriptor.kind} (${descriptor.fileName}): ${describeWechatTransportError(error)}`;
918
+ this.logger.logError(message);
919
+ failureLines.push(`[${message}]`);
920
+ }
921
+ }
922
+ return { attachments, failureLines };
923
+ }
924
+ async sendText(senderId, text) {
925
+ const trimmed = text.trim();
926
+ if (!trimmed) {
927
+ return;
928
+ }
929
+ const resolved = this.resolveRecipient(senderId);
930
+ await this.sendTextWithContextToken(resolved.account, resolved.recipientId, trimmed, resolved.contextToken);
931
+ }
932
+ async sendNotification(message, recipientId) {
933
+ const trimmed = message.trim();
934
+ if (!trimmed) {
935
+ throw new Error("Notification text cannot be empty.");
936
+ }
937
+ const resolved = this.resolveRecipient(recipientId);
938
+ await this.sendTextWithContextToken(resolved.account, resolved.recipientId, trimmed, resolved.contextToken);
939
+ return resolved.recipientId;
940
+ }
941
+ async sendImage(imagePath, options = {}) {
942
+ const resolved = this.resolveRecipient(options.recipientId);
943
+ const caption = options.caption?.trim();
944
+ if (caption) {
945
+ await this.sendTextWithContextToken(resolved.account, resolved.recipientId, caption, resolved.contextToken);
946
+ }
947
+ const upload = await this.prepareUpload(resolved.account, resolved.recipientId, imagePath, UPLOAD_MEDIA_TYPE_IMAGE, "image");
948
+ await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
949
+ {
950
+ type: MSG_ITEM_IMAGE,
951
+ image_item: {
952
+ media: {
953
+ encrypt_query_param: upload.downloadParam,
954
+ aes_key: encodeMessageAesKey(upload.aeskey),
955
+ encrypt_type: 1,
956
+ },
957
+ mid_size: upload.filesize,
958
+ },
959
+ },
960
+ ]);
961
+ return resolved.recipientId;
962
+ }
963
+ async sendFile(filePath, options = {}) {
964
+ const resolved = this.resolveRecipient(options.recipientId);
965
+ const upload = await this.prepareUpload(resolved.account, resolved.recipientId, filePath, UPLOAD_MEDIA_TYPE_FILE, "file");
966
+ const fileName = options.title?.trim() || path.basename(filePath);
967
+ await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
968
+ {
969
+ type: MSG_ITEM_FILE,
970
+ file_item: {
971
+ file_name: fileName,
972
+ len: String(upload.rawsize),
973
+ media: {
974
+ encrypt_query_param: upload.downloadParam,
975
+ aes_key: encodeMessageAesKey(upload.aeskey),
976
+ encrypt_type: 1,
977
+ },
978
+ },
979
+ },
980
+ ]);
981
+ return resolved.recipientId;
982
+ }
983
+ async sendVoice(voicePath, recipientId) {
984
+ const resolved = this.resolveRecipient(recipientId);
985
+ const upload = await this.prepareUpload(resolved.account, resolved.recipientId, voicePath, UPLOAD_MEDIA_TYPE_VOICE, "voice");
986
+ await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
987
+ {
988
+ type: MSG_ITEM_VOICE,
989
+ voice_item: {
990
+ media: {
991
+ encrypt_query_param: upload.downloadParam,
992
+ aes_key: encodeMessageAesKey(upload.aeskey),
993
+ encrypt_type: 1,
994
+ },
995
+ },
996
+ },
997
+ ]);
998
+ return resolved.recipientId;
999
+ }
1000
+ async sendVideo(videoPath, options = {}) {
1001
+ const resolved = this.resolveRecipient(options.recipientId);
1002
+ const title = options.title?.trim();
1003
+ if (title) {
1004
+ await this.sendTextWithContextToken(resolved.account, resolved.recipientId, title, resolved.contextToken);
1005
+ }
1006
+ const upload = await this.prepareUpload(resolved.account, resolved.recipientId, videoPath, UPLOAD_MEDIA_TYPE_VIDEO, "video");
1007
+ await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
1008
+ {
1009
+ type: MSG_ITEM_VIDEO,
1010
+ video_item: {
1011
+ media: {
1012
+ encrypt_query_param: upload.downloadParam,
1013
+ aes_key: encodeMessageAesKey(upload.aeskey),
1014
+ encrypt_type: 1,
1015
+ },
1016
+ video_size: upload.filesize,
1017
+ },
1018
+ },
1019
+ ]);
1020
+ return resolved.recipientId;
1021
+ }
1022
+ requireAccount() {
1023
+ const account = this.getCredentials();
1024
+ if (!account) {
1025
+ throw new Error(`No saved WeChat credentials found. Start a bridge command in a terminal to log in automatically, or run "wechat-setup". Expected file: ${CREDENTIALS_FILE}`);
1026
+ }
1027
+ return account;
1028
+ }
1029
+ resolveRecipient(recipientId) {
1030
+ const account = this.requireAccount();
1031
+ let resolvedRecipientId = recipientId?.trim();
1032
+ if (!resolvedRecipientId) {
1033
+ const recipients = [...this.contextTokenCache.keys()];
1034
+ resolvedRecipientId = recipients[recipients.length - 1];
1035
+ if (!resolvedRecipientId) {
1036
+ throw new Error("No cached context token is available. Fetch messages first or ask the user to send a new WeChat message.");
1037
+ }
1038
+ }
1039
+ const contextToken = this.contextTokenCache.get(resolvedRecipientId);
1040
+ if (!contextToken) {
1041
+ throw new Error(`No cached context token for ${resolvedRecipientId}. Fetch messages first or ask the user to send a new WeChat message.`);
1042
+ }
1043
+ return { account, recipientId: resolvedRecipientId, contextToken };
1044
+ }
1045
+ async sendTextWithContextToken(account, recipientId, text, contextToken) {
1046
+ const trimmed = text.trim();
1047
+ if (!trimmed) {
1048
+ return;
1049
+ }
1050
+ await this.sendMessage(account, recipientId, contextToken, [
1051
+ { type: MSG_ITEM_TEXT, text_item: { text: trimmed } },
1052
+ ]);
1053
+ }
1054
+ async sendMessage(account, recipientId, contextToken, itemList) {
1055
+ const raw = await apiFetch({
1056
+ baseUrl: account.baseUrl,
1057
+ endpoint: "ilink/bot/sendmessage",
1058
+ body: JSON.stringify({
1059
+ msg: {
1060
+ from_user_id: "",
1061
+ to_user_id: recipientId,
1062
+ client_id: this.generateClientId(),
1063
+ message_type: MSG_TYPE_BOT,
1064
+ message_state: MSG_STATE_FINISH,
1065
+ item_list: itemList,
1066
+ context_token: contextToken,
1067
+ },
1068
+ base_info: { channel_version: CHANNEL_VERSION },
1069
+ }),
1070
+ token: account.token,
1071
+ timeoutMs: SEND_TIMEOUT_MS,
1072
+ });
1073
+ assertWechatApiResponseOk("sendmessage", raw);
1074
+ }
1075
+ async prepareUpload(account, recipientId, filePath, mediaType, label) {
1076
+ const stat = this.requireExistingFile(filePath);
1077
+ assertMediaUploadSizeAllowed(label, stat.size);
1078
+ const plaintext = fs.readFileSync(filePath);
1079
+ const rawsize = plaintext.length;
1080
+ const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
1081
+ const filesize = aesEcbPaddedSize(rawsize);
1082
+ const filekey = crypto.randomBytes(16).toString("hex");
1083
+ const aeskey = crypto.randomBytes(16);
1084
+ this.logger.log(`Uploading ${label}: ${filePath} (${rawsize} bytes, md5=${rawfilemd5})`);
1085
+ const uploadResp = await getUploadUrl(account, {
1086
+ filekey,
1087
+ media_type: mediaType,
1088
+ to_user_id: recipientId,
1089
+ rawsize,
1090
+ rawfilemd5,
1091
+ filesize,
1092
+ aeskey: aeskey.toString("hex"),
1093
+ });
1094
+ if (!uploadResp.upload_param) {
1095
+ throw new Error("getUploadUrl returned no upload_param");
1096
+ }
1097
+ const { downloadParam } = await uploadBufferToCdn({
1098
+ buf: plaintext,
1099
+ uploadParam: uploadResp.upload_param,
1100
+ filekey,
1101
+ aeskey,
1102
+ onRetry: (attempt) => {
1103
+ this.logger.log(`CDN upload attempt ${attempt} failed for ${label}, retrying...`);
1104
+ },
1105
+ });
1106
+ this.logger.log(`${label} upload complete, downloadParam length=${downloadParam.length}`);
1107
+ return {
1108
+ rawsize,
1109
+ filesize,
1110
+ aeskey,
1111
+ downloadParam,
1112
+ };
1113
+ }
1114
+ requireExistingFile(filePath) {
1115
+ let stat;
1116
+ try {
1117
+ stat = fs.statSync(filePath);
1118
+ }
1119
+ catch {
1120
+ throw new Error(`File not found: ${filePath}`);
1121
+ }
1122
+ if (!stat.isFile()) {
1123
+ throw new Error(`Not a file: ${filePath}`);
1124
+ }
1125
+ return stat;
1126
+ }
1127
+ async getUpdates(account, timeoutMs) {
1128
+ try {
1129
+ const raw = await apiFetch({
1130
+ baseUrl: account.baseUrl,
1131
+ endpoint: "ilink/bot/getupdates",
1132
+ body: JSON.stringify({
1133
+ get_updates_buf: this.syncBuffer,
1134
+ base_info: { channel_version: CHANNEL_VERSION },
1135
+ }),
1136
+ token: account.token,
1137
+ timeoutMs,
1138
+ });
1139
+ return JSON.parse(raw);
1140
+ }
1141
+ catch (err) {
1142
+ if (err instanceof Error && err.name === "AbortError") {
1143
+ return { ret: 0, msgs: [], get_updates_buf: this.syncBuffer };
1144
+ }
1145
+ throw err;
1146
+ }
1147
+ }
1148
+ rememberMessage(key) {
1149
+ if (!key || this.recentMessageKeys.has(key)) {
1150
+ return false;
1151
+ }
1152
+ this.recentMessageKeys.add(key);
1153
+ this.recentMessageOrder.push(key);
1154
+ while (this.recentMessageOrder.length > RECENT_MESSAGE_CACHE_SIZE) {
1155
+ const oldest = this.recentMessageOrder.shift();
1156
+ if (oldest) {
1157
+ this.recentMessageKeys.delete(oldest);
1158
+ }
1159
+ }
1160
+ return true;
1161
+ }
1162
+ clearRecentMessages() {
1163
+ this.recentMessageKeys.clear();
1164
+ this.recentMessageOrder.length = 0;
1165
+ }
1166
+ readSyncBuffer() {
1167
+ try {
1168
+ if (!fs.existsSync(SYNC_BUF_FILE)) {
1169
+ return "";
1170
+ }
1171
+ return fs.readFileSync(SYNC_BUF_FILE, "utf-8");
1172
+ }
1173
+ catch (err) {
1174
+ this.logger.logError(`Failed to read sync state: ${String(err)}`);
1175
+ return "";
1176
+ }
1177
+ }
1178
+ saveSyncBuffer(syncBuffer) {
1179
+ ensureChannelDataDir();
1180
+ fs.writeFileSync(SYNC_BUF_FILE, syncBuffer, "utf-8");
1181
+ }
1182
+ clearSyncBuffer() {
1183
+ this.syncBuffer = "";
1184
+ if (fs.existsSync(SYNC_BUF_FILE)) {
1185
+ fs.rmSync(SYNC_BUF_FILE, { force: true });
1186
+ }
1187
+ }
1188
+ cacheContextToken(senderId, token) {
1189
+ if (this.contextTokenCache.has(senderId)) {
1190
+ this.contextTokenCache.delete(senderId);
1191
+ }
1192
+ this.contextTokenCache.set(senderId, token);
1193
+ writeJsonFile(CONTEXT_CACHE_FILE, Object.fromEntries(this.contextTokenCache));
1194
+ }
1195
+ clearContextTokenCache() {
1196
+ this.contextTokenCache.clear();
1197
+ if (fs.existsSync(CONTEXT_CACHE_FILE)) {
1198
+ fs.rmSync(CONTEXT_CACHE_FILE, { force: true });
1199
+ }
1200
+ }
1201
+ clearCachedContextToken(recipientId) {
1202
+ const normalizedRecipientId = recipientId.trim();
1203
+ if (!normalizedRecipientId || !this.contextTokenCache.has(normalizedRecipientId)) {
1204
+ return false;
1205
+ }
1206
+ this.contextTokenCache.delete(normalizedRecipientId);
1207
+ writeJsonFile(CONTEXT_CACHE_FILE, Object.fromEntries(this.contextTokenCache));
1208
+ return true;
1209
+ }
1210
+ generateClientId() {
1211
+ return `wechat-bridge:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
1212
+ }
1213
+ }