@unlinearity/cli-wechat-bridge 1.0.0 → 1.0.2

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 (81) hide show
  1. package/bin/_run-entry.mjs +2 -8
  2. package/bin/wechat-bridge-claude.mjs +2 -2
  3. package/bin/wechat-bridge-codex.mjs +2 -2
  4. package/bin/wechat-bridge-opencode.mjs +2 -2
  5. package/bin/wechat-bridge-shell.mjs +2 -2
  6. package/bin/wechat-bridge.mjs +2 -2
  7. package/bin/wechat-check-update.mjs +2 -2
  8. package/bin/wechat-claude-start.mjs +2 -2
  9. package/bin/wechat-claude.mjs +2 -2
  10. package/bin/wechat-codex-start.mjs +2 -2
  11. package/bin/wechat-codex.mjs +2 -2
  12. package/bin/wechat-opencode-start.mjs +2 -2
  13. package/bin/wechat-opencode.mjs +2 -2
  14. package/bin/wechat-setup.mjs +5 -0
  15. package/dist/bridge/bridge-adapters.claude.js +829 -0
  16. package/dist/bridge/bridge-adapters.codex.js +2206 -0
  17. package/dist/bridge/bridge-adapters.core.js +668 -0
  18. package/dist/bridge/bridge-adapters.js +26 -0
  19. package/dist/bridge/bridge-adapters.opencode.js +1777 -0
  20. package/dist/bridge/bridge-adapters.shared.js +1098 -0
  21. package/dist/bridge/bridge-adapters.shell.js +363 -0
  22. package/dist/bridge/bridge-controller.js +44 -0
  23. package/dist/bridge/bridge-final-reply.js +31 -0
  24. package/dist/bridge/bridge-process-reaper.js +348 -0
  25. package/dist/bridge/bridge-state.js +356 -0
  26. package/dist/bridge/bridge-types.js +1 -0
  27. package/dist/bridge/bridge-utils.js +1150 -0
  28. package/dist/bridge/claude-hook.js +82 -0
  29. package/dist/bridge/claude-hooks.js +267 -0
  30. package/dist/bridge/wechat-bridge.js +904 -0
  31. package/dist/commands/check-update.js +30 -0
  32. package/dist/companion/codex-panel-link.js +72 -0
  33. package/dist/companion/codex-panel.js +179 -0
  34. package/dist/companion/codex-remote-client.js +117 -0
  35. package/dist/companion/local-companion-link.js +210 -0
  36. package/dist/companion/local-companion-start.js +372 -0
  37. package/dist/companion/local-companion.js +411 -0
  38. package/dist/media/media-types.js +1 -0
  39. package/dist/runtime/create-runtime-host.js +12 -0
  40. package/dist/runtime/legacy-adapter-runtime.js +40 -0
  41. package/dist/runtime/runtime-types.js +5 -0
  42. package/dist/utils/version-checker.js +161 -0
  43. package/dist/wechat/channel-config.js +103 -0
  44. package/dist/wechat/setup.js +283 -0
  45. package/dist/wechat/standalone-bot.js +356 -0
  46. package/dist/wechat/wechat-channel.js +481 -0
  47. package/dist/wechat/wechat-transport.js +887 -0
  48. package/package.json +6 -2
  49. package/src/bridge/bridge-adapters.claude.ts +0 -1047
  50. package/src/bridge/bridge-adapters.codex.ts +0 -2733
  51. package/src/bridge/bridge-adapters.core.ts +0 -877
  52. package/src/bridge/bridge-adapters.opencode.ts +0 -2390
  53. package/src/bridge/bridge-adapters.shared.ts +0 -1647
  54. package/src/bridge/bridge-adapters.shell.ts +0 -456
  55. package/src/bridge/bridge-adapters.ts +0 -30
  56. package/src/bridge/bridge-controller.ts +0 -58
  57. package/src/bridge/bridge-final-reply.ts +0 -56
  58. package/src/bridge/bridge-process-reaper.ts +0 -417
  59. package/src/bridge/bridge-state.ts +0 -520
  60. package/src/bridge/bridge-types.ts +0 -218
  61. package/src/bridge/bridge-utils.ts +0 -1524
  62. package/src/bridge/claude-hook.ts +0 -92
  63. package/src/bridge/claude-hooks.ts +0 -376
  64. package/src/bridge/wechat-bridge.ts +0 -1305
  65. package/src/commands/check-update.ts +0 -46
  66. package/src/companion/codex-panel-link.ts +0 -151
  67. package/src/companion/codex-panel.ts +0 -225
  68. package/src/companion/codex-remote-client.ts +0 -178
  69. package/src/companion/local-companion-link.ts +0 -338
  70. package/src/companion/local-companion-start.ts +0 -520
  71. package/src/companion/local-companion.ts +0 -532
  72. package/src/media/media-types.ts +0 -11
  73. package/src/runtime/create-runtime-host.ts +0 -16
  74. package/src/runtime/legacy-adapter-runtime.ts +0 -55
  75. package/src/runtime/runtime-types.ts +0 -51
  76. package/src/utils/version-checker.ts +0 -197
  77. package/src/wechat/channel-config.ts +0 -141
  78. package/src/wechat/setup.ts +0 -232
  79. package/src/wechat/standalone-bot.ts +0 -483
  80. package/src/wechat/wechat-channel.ts +0 -608
  81. package/src/wechat/wechat-transport.ts +0 -1290
@@ -0,0 +1,887 @@
1
+ import crypto from "node:crypto";
2
+ import { createCipheriv } from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { CONTEXT_CACHE_FILE, CREDENTIALS_FILE, ensureChannelDataDir, 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 CDN_MAX_RETRIES = 3;
13
+ const ERROR_CAUSE_DEPTH_LIMIT = 4;
14
+ const INBOUND_MESSAGE_CLAIM_TTL_MS = 10 * 60 * 1000;
15
+ const SYNC_SESSION_TIMEOUT_ERRCODE = -14;
16
+ const MSG_TYPE_USER = 1;
17
+ const MSG_TYPE_BOT = 2;
18
+ const MSG_ITEM_TEXT = 1;
19
+ const MSG_ITEM_IMAGE = 2;
20
+ const MSG_ITEM_VOICE = 3;
21
+ const MSG_ITEM_FILE = 4;
22
+ const MSG_ITEM_VIDEO = 5;
23
+ const MSG_STATE_FINISH = 2;
24
+ const UPLOAD_MEDIA_TYPE_IMAGE = 1;
25
+ const UPLOAD_MEDIA_TYPE_VIDEO = 2;
26
+ const UPLOAD_MEDIA_TYPE_FILE = 3;
27
+ const UPLOAD_MEDIA_TYPE_VOICE = 4;
28
+ export function isWechatSyncSessionTimeout(response) {
29
+ return (response.errcode === SYNC_SESSION_TIMEOUT_ERRCODE &&
30
+ /session timeout/i.test(response.errmsg ?? ""));
31
+ }
32
+ const DEFAULT_MEDIA_UPLOAD_LIMIT_MB = {
33
+ image: 20,
34
+ file: 50,
35
+ voice: 20,
36
+ video: 100,
37
+ };
38
+ const MEDIA_UPLOAD_LIMIT_ENV_KEYS = {
39
+ image: "WECHAT_MAX_IMAGE_MB",
40
+ file: "WECHAT_MAX_FILE_MB",
41
+ voice: "WECHAT_MAX_VOICE_MB",
42
+ video: "WECHAT_MAX_VIDEO_MB",
43
+ };
44
+ const RETRYABLE_HTTP_STATUS_CODES = new Set([408, 425, 429]);
45
+ const RETRYABLE_NETWORK_ERROR_CODES = new Set([
46
+ "ECONNABORTED",
47
+ "ECONNREFUSED",
48
+ "ECONNRESET",
49
+ "EHOSTUNREACH",
50
+ "EPIPE",
51
+ "ETIMEDOUT",
52
+ "ENETUNREACH",
53
+ "ENOTFOUND",
54
+ "EAI_AGAIN",
55
+ "UND_ERR_CONNECT_TIMEOUT",
56
+ "UND_ERR_HEADERS_TIMEOUT",
57
+ "UND_ERR_SOCKET",
58
+ ]);
59
+ const RETRYABLE_NETWORK_ERROR_HINTS = [
60
+ "connection closed",
61
+ "connection reset",
62
+ "connection refused",
63
+ "econnaborted",
64
+ "econnrefused",
65
+ "econnreset",
66
+ "ehostunreach",
67
+ "enetunreach",
68
+ "enotfound",
69
+ "eai_again",
70
+ "fetch failed",
71
+ "network error",
72
+ "request timeout",
73
+ "socket hang up",
74
+ "timed out",
75
+ "timeout",
76
+ ];
77
+ function readJsonFile(filePath) {
78
+ try {
79
+ if (!fs.existsSync(filePath)) {
80
+ return null;
81
+ }
82
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ function isRecord(value) {
89
+ return typeof value === "object" && value !== null;
90
+ }
91
+ function describeErrorNode(value) {
92
+ if (value instanceof Error) {
93
+ const error = value;
94
+ const parts = [];
95
+ if (error.name && error.message) {
96
+ parts.push(`${error.name}: ${error.message}`);
97
+ }
98
+ else if (error.message) {
99
+ parts.push(error.message);
100
+ }
101
+ else if (error.name) {
102
+ parts.push(error.name);
103
+ }
104
+ if (typeof error.code === "string" && error.code.trim()) {
105
+ parts.push(`code=${error.code}`);
106
+ }
107
+ if ((typeof error.errno === "string" && error.errno.trim()) ||
108
+ typeof error.errno === "number") {
109
+ parts.push(`errno=${error.errno}`);
110
+ }
111
+ if (typeof error.syscall === "string" && error.syscall.trim()) {
112
+ parts.push(`syscall=${error.syscall}`);
113
+ }
114
+ if (typeof error.hostname === "string" && error.hostname.trim()) {
115
+ parts.push(`host=${error.hostname}`);
116
+ }
117
+ if (typeof error.address === "string" && error.address.trim()) {
118
+ parts.push(`address=${error.address}`);
119
+ }
120
+ if ((typeof error.port === "string" && error.port.trim()) ||
121
+ typeof error.port === "number") {
122
+ parts.push(`port=${error.port}`);
123
+ }
124
+ return parts.filter(Boolean).join(" ");
125
+ }
126
+ if (isRecord(value)) {
127
+ const parts = [];
128
+ if (typeof value.message === "string" && value.message.trim()) {
129
+ parts.push(value.message);
130
+ }
131
+ if (typeof value.code === "string" && value.code.trim()) {
132
+ parts.push(`code=${value.code}`);
133
+ }
134
+ if ((typeof value.errno === "string" && value.errno.trim()) ||
135
+ typeof value.errno === "number") {
136
+ parts.push(`errno=${value.errno}`);
137
+ }
138
+ if (typeof value.syscall === "string" && value.syscall.trim()) {
139
+ parts.push(`syscall=${value.syscall}`);
140
+ }
141
+ if (typeof value.hostname === "string" && value.hostname.trim()) {
142
+ parts.push(`host=${value.hostname}`);
143
+ }
144
+ if (typeof value.address === "string" && value.address.trim()) {
145
+ parts.push(`address=${value.address}`);
146
+ }
147
+ if ((typeof value.port === "string" && value.port.trim()) ||
148
+ typeof value.port === "number") {
149
+ parts.push(`port=${value.port}`);
150
+ }
151
+ if (parts.length > 0) {
152
+ return parts.join(" ");
153
+ }
154
+ }
155
+ return String(value);
156
+ }
157
+ function getErrorCause(value) {
158
+ if (value instanceof Error) {
159
+ return value.cause;
160
+ }
161
+ if (isRecord(value) && "cause" in value) {
162
+ return value.cause;
163
+ }
164
+ return undefined;
165
+ }
166
+ function collectErrorCodes(value) {
167
+ const seen = new Set();
168
+ const codes = new Set();
169
+ let current = value;
170
+ let depth = 0;
171
+ while (current && depth < ERROR_CAUSE_DEPTH_LIMIT && !seen.has(current)) {
172
+ seen.add(current);
173
+ if (isRecord(current) && typeof current.code === "string" && current.code.trim()) {
174
+ codes.add(current.code.toUpperCase());
175
+ }
176
+ current = getErrorCause(current);
177
+ depth += 1;
178
+ }
179
+ return [...codes];
180
+ }
181
+ function extractHttpStatusCode(error) {
182
+ const match = /^HTTP (\d{3}):/.exec(error.message);
183
+ if (!match) {
184
+ return null;
185
+ }
186
+ const parsed = Number(match[1]);
187
+ return Number.isFinite(parsed) ? parsed : null;
188
+ }
189
+ export function describeWechatTransportError(error) {
190
+ const seen = new Set();
191
+ const parts = [];
192
+ let current = error;
193
+ let depth = 0;
194
+ while (current && depth < ERROR_CAUSE_DEPTH_LIMIT && !seen.has(current)) {
195
+ seen.add(current);
196
+ const description = describeErrorNode(current);
197
+ if (description) {
198
+ parts.push(depth === 0 ? description : `cause: ${description}`);
199
+ }
200
+ current = getErrorCause(current);
201
+ depth += 1;
202
+ }
203
+ return parts.length > 0 ? parts.join(" | ") : String(error);
204
+ }
205
+ export function classifyWechatTransportError(error) {
206
+ if (error instanceof Error) {
207
+ if (/WeChat session timed out/i.test(error.message) ||
208
+ /errcode=-14\b.*session timeout/i.test(error.message)) {
209
+ return { kind: "auth", retryable: false };
210
+ }
211
+ if (error.name === "AbortError") {
212
+ return { kind: "timeout", retryable: true };
213
+ }
214
+ const statusCode = extractHttpStatusCode(error);
215
+ if (statusCode !== null) {
216
+ if (statusCode === 401 || statusCode === 403) {
217
+ return { kind: "auth", retryable: false, statusCode };
218
+ }
219
+ if (statusCode >= 500 || RETRYABLE_HTTP_STATUS_CODES.has(statusCode)) {
220
+ return { kind: "http", retryable: true, statusCode };
221
+ }
222
+ return { kind: "http", retryable: false, statusCode };
223
+ }
224
+ }
225
+ const errorCodes = collectErrorCodes(error);
226
+ if (errorCodes.some((code) => RETRYABLE_NETWORK_ERROR_CODES.has(code))) {
227
+ return { kind: "network", retryable: true };
228
+ }
229
+ const details = describeWechatTransportError(error).toLowerCase();
230
+ if (RETRYABLE_NETWORK_ERROR_HINTS.some((hint) => details.includes(hint))) {
231
+ return { kind: "network", retryable: true };
232
+ }
233
+ return { kind: "unknown", retryable: false };
234
+ }
235
+ function writeJsonFile(filePath, value) {
236
+ ensureChannelDataDir();
237
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2), "utf-8");
238
+ }
239
+ function randomWechatUin() {
240
+ const uint32 = crypto.randomBytes(4).readUInt32BE(0);
241
+ return Buffer.from(String(uint32), "utf-8").toString("base64");
242
+ }
243
+ function buildHeaders(token, body) {
244
+ const headers = {
245
+ "Content-Type": "application/json",
246
+ AuthorizationType: "ilink_bot_token",
247
+ "X-WECHAT-UIN": randomWechatUin(),
248
+ };
249
+ if (body) {
250
+ headers["Content-Length"] = String(Buffer.byteLength(body, "utf-8"));
251
+ }
252
+ if (token?.trim()) {
253
+ headers.Authorization = `Bearer ${token.trim()}`;
254
+ }
255
+ return headers;
256
+ }
257
+ async function apiFetch(params) {
258
+ const base = params.baseUrl.endsWith("/") ? params.baseUrl : `${params.baseUrl}/`;
259
+ const url = new URL(params.endpoint, base).toString();
260
+ const controller = new AbortController();
261
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs);
262
+ try {
263
+ const res = await fetch(url, {
264
+ method: "POST",
265
+ headers: buildHeaders(params.token, params.body),
266
+ body: params.body,
267
+ signal: controller.signal,
268
+ });
269
+ clearTimeout(timer);
270
+ const text = await res.text();
271
+ if (!res.ok) {
272
+ throw new Error(`HTTP ${res.status}: ${text}`);
273
+ }
274
+ return text;
275
+ }
276
+ catch (err) {
277
+ clearTimeout(timer);
278
+ throw err;
279
+ }
280
+ }
281
+ function encryptAesEcb(plaintext, key) {
282
+ const cipher = createCipheriv("aes-128-ecb", key, null);
283
+ return Buffer.concat([cipher.update(plaintext), cipher.final()]);
284
+ }
285
+ function aesEcbPaddedSize(plaintextSize) {
286
+ return Math.ceil((plaintextSize + 1) / 16) * 16;
287
+ }
288
+ export function formatByteSize(bytes) {
289
+ if (!Number.isFinite(bytes) || bytes < 0) {
290
+ return "0 B";
291
+ }
292
+ if (bytes >= BYTES_PER_MB) {
293
+ const value = bytes / BYTES_PER_MB;
294
+ return `${value.toFixed(value >= 100 ? 0 : 1)} MB`;
295
+ }
296
+ if (bytes >= 1024) {
297
+ const value = bytes / 1024;
298
+ return `${value.toFixed(value >= 100 ? 0 : 1)} KB`;
299
+ }
300
+ return `${bytes} B`;
301
+ }
302
+ export function resolveMediaUploadLimitBytes(label, env = process.env) {
303
+ const envKey = MEDIA_UPLOAD_LIMIT_ENV_KEYS[label];
304
+ const raw = env[envKey];
305
+ const fallbackMb = DEFAULT_MEDIA_UPLOAD_LIMIT_MB[label];
306
+ const parsedMb = raw ? Number(raw) : Number.NaN;
307
+ const limitMb = Number.isFinite(parsedMb) && parsedMb > 0 ? parsedMb : fallbackMb;
308
+ return Math.floor(limitMb * BYTES_PER_MB);
309
+ }
310
+ export function assertMediaUploadSizeAllowed(label, rawsize, env = process.env) {
311
+ const limitBytes = resolveMediaUploadLimitBytes(label, env);
312
+ if (rawsize <= limitBytes) {
313
+ return;
314
+ }
315
+ const envKey = MEDIA_UPLOAD_LIMIT_ENV_KEYS[label];
316
+ const labelName = label.charAt(0).toUpperCase() + label.slice(1);
317
+ throw new Error(`${labelName} too large: ${formatByteSize(rawsize)} exceeds ${formatByteSize(limitBytes)} limit. Set ${envKey} to override.`);
318
+ }
319
+ function encodeMessageAesKey(aeskey) {
320
+ return Buffer.from(aeskey.toString("hex")).toString("base64");
321
+ }
322
+ async function getUploadUrl(account, params) {
323
+ const raw = await apiFetch({
324
+ baseUrl: account.baseUrl,
325
+ endpoint: "ilink/bot/getuploadurl",
326
+ body: JSON.stringify({
327
+ ...params,
328
+ no_need_thumb: true,
329
+ base_info: { channel_version: CHANNEL_VERSION },
330
+ }),
331
+ token: account.token,
332
+ timeoutMs: SEND_TIMEOUT_MS,
333
+ });
334
+ return JSON.parse(raw);
335
+ }
336
+ function buildCdnUploadUrl(cdnBaseUrl, uploadParam, filekey) {
337
+ return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
338
+ }
339
+ async function uploadBufferToCdn(params) {
340
+ const ciphertext = encryptAesEcb(params.buf, params.aeskey);
341
+ const cdnUrl = buildCdnUploadUrl(CDN_BASE_URL, params.uploadParam, params.filekey);
342
+ let downloadParam;
343
+ let lastError;
344
+ for (let attempt = 1; attempt <= CDN_MAX_RETRIES; attempt += 1) {
345
+ try {
346
+ const res = await fetch(cdnUrl, {
347
+ method: "POST",
348
+ headers: { "Content-Type": "application/octet-stream" },
349
+ body: new Uint8Array(ciphertext),
350
+ });
351
+ if (res.status >= 400 && res.status < 500) {
352
+ const errMsg = res.headers.get("x-error-message") ?? (await res.text());
353
+ throw new Error(`CDN client error ${res.status}: ${errMsg}`);
354
+ }
355
+ if (res.status !== 200) {
356
+ const errMsg = res.headers.get("x-error-message") ?? `status ${res.status}`;
357
+ throw new Error(`CDN server error: ${errMsg}`);
358
+ }
359
+ downloadParam = res.headers.get("x-encrypted-param") ?? undefined;
360
+ if (!downloadParam) {
361
+ throw new Error("CDN response missing x-encrypted-param header");
362
+ }
363
+ break;
364
+ }
365
+ catch (err) {
366
+ lastError = err;
367
+ if (err instanceof Error && err.message.includes("client error")) {
368
+ throw err;
369
+ }
370
+ if (attempt >= CDN_MAX_RETRIES) {
371
+ break;
372
+ }
373
+ params.onRetry?.(attempt);
374
+ }
375
+ }
376
+ if (!downloadParam) {
377
+ throw lastError instanceof Error ? lastError : new Error("CDN upload failed");
378
+ }
379
+ return { downloadParam };
380
+ }
381
+ function extractReferenceLabel(item) {
382
+ const ref = item.ref_msg;
383
+ if (!ref) {
384
+ return null;
385
+ }
386
+ const parts = [];
387
+ if (ref.title?.trim()) {
388
+ parts.push(ref.title.trim());
389
+ }
390
+ const quotedText = ref.message_item?.text_item?.text?.trim();
391
+ if (quotedText) {
392
+ parts.push(quotedText);
393
+ }
394
+ return parts.length ? `Quoted: ${parts.join(" | ")}` : null;
395
+ }
396
+ function extractTextFromMessage(message) {
397
+ if (!message.item_list?.length) {
398
+ return "";
399
+ }
400
+ const lines = [];
401
+ for (const item of message.item_list) {
402
+ const reference = extractReferenceLabel(item);
403
+ if (reference && !lines.includes(reference)) {
404
+ lines.push(reference);
405
+ }
406
+ if (item.type === MSG_ITEM_TEXT) {
407
+ const text = item.text_item?.text?.trim();
408
+ if (text) {
409
+ lines.push(text);
410
+ }
411
+ }
412
+ if (item.type === MSG_ITEM_VOICE) {
413
+ const transcript = item.voice_item?.text?.trim();
414
+ if (transcript) {
415
+ lines.push(transcript);
416
+ }
417
+ }
418
+ }
419
+ return lines.join("\n").trim();
420
+ }
421
+ function buildMessageKey(message) {
422
+ return [
423
+ message.from_user_id ?? "",
424
+ message.client_id ?? "",
425
+ String(message.create_time_ms ?? ""),
426
+ message.context_token ?? "",
427
+ ].join("|");
428
+ }
429
+ function buildScopedMessageClaimKey(accountId, messageKey) {
430
+ return `${accountId}|${messageKey}`;
431
+ }
432
+ export function buildInboundMessageClaimPath(messageKey, claimsDir = INBOUND_MESSAGE_CLAIMS_DIR) {
433
+ const fileName = `${crypto.createHash("sha1").update(messageKey).digest("hex")}.json`;
434
+ return path.join(claimsDir, fileName);
435
+ }
436
+ export function clearInboundMessageClaims(claimsDir = INBOUND_MESSAGE_CLAIMS_DIR) {
437
+ try {
438
+ fs.rmSync(claimsDir, { recursive: true, force: true });
439
+ }
440
+ catch {
441
+ // Best effort cleanup.
442
+ }
443
+ }
444
+ export function tryClaimInboundMessage(messageKey, options = {}) {
445
+ if (!messageKey) {
446
+ return false;
447
+ }
448
+ const claimsDir = options.claimsDir ?? INBOUND_MESSAGE_CLAIMS_DIR;
449
+ const nowMs = options.nowMs ?? Date.now();
450
+ const ttlMs = options.ttlMs ?? INBOUND_MESSAGE_CLAIM_TTL_MS;
451
+ const claimPath = buildInboundMessageClaimPath(messageKey, claimsDir);
452
+ const attemptClaim = () => {
453
+ fs.mkdirSync(claimsDir, { recursive: true });
454
+ const handle = fs.openSync(claimPath, "wx");
455
+ try {
456
+ fs.writeFileSync(handle, JSON.stringify({
457
+ key: messageKey,
458
+ claimedAt: new Date(nowMs).toISOString(),
459
+ pid: process.pid,
460
+ }, null, 2), "utf-8");
461
+ }
462
+ finally {
463
+ fs.closeSync(handle);
464
+ }
465
+ return true;
466
+ };
467
+ try {
468
+ return attemptClaim();
469
+ }
470
+ catch (error) {
471
+ const code = typeof error === "object" &&
472
+ error !== null &&
473
+ "code" in error &&
474
+ typeof error.code === "string"
475
+ ? error.code
476
+ : "";
477
+ if (code !== "EEXIST") {
478
+ return true;
479
+ }
480
+ }
481
+ try {
482
+ const stat = fs.statSync(claimPath);
483
+ if (Number.isFinite(stat.mtimeMs) && nowMs - stat.mtimeMs > ttlMs) {
484
+ fs.rmSync(claimPath, { force: true });
485
+ return attemptClaim();
486
+ }
487
+ }
488
+ catch {
489
+ return attemptClaim();
490
+ }
491
+ return false;
492
+ }
493
+ function normalizeSender(senderId) {
494
+ return senderId.split("@")[0] || senderId;
495
+ }
496
+ function formatTimestamp(timestampMs) {
497
+ if (!timestampMs) {
498
+ return new Date().toISOString();
499
+ }
500
+ return new Date(timestampMs).toISOString();
501
+ }
502
+ export class WeChatTransport {
503
+ logger;
504
+ recentMessageKeys = new Set();
505
+ recentMessageOrder = [];
506
+ contextTokenCache = new Map(Object.entries(readJsonFile(CONTEXT_CACHE_FILE) ?? {}));
507
+ syncBuffer = "";
508
+ constructor(logger) {
509
+ this.logger = logger;
510
+ migrateLegacyChannelFiles((message) => this.logger.log(message));
511
+ this.syncBuffer = this.readSyncBuffer();
512
+ }
513
+ getCredentials() {
514
+ return readJsonFile(CREDENTIALS_FILE);
515
+ }
516
+ getStatusText() {
517
+ const account = this.getCredentials();
518
+ const syncExists = fs.existsSync(SYNC_BUF_FILE);
519
+ const contextExists = fs.existsSync(CONTEXT_CACHE_FILE);
520
+ return [
521
+ `credentials_file: ${CREDENTIALS_FILE}`,
522
+ `credentials_present: ${account ? "yes" : "no"}`,
523
+ `sync_state_file: ${SYNC_BUF_FILE}`,
524
+ `sync_state_present: ${syncExists ? "yes" : "no"}`,
525
+ `context_cache_file: ${CONTEXT_CACHE_FILE}`,
526
+ `context_cache_present: ${contextExists ? "yes" : "no"}`,
527
+ `cached_context_count: ${this.contextTokenCache.size}`,
528
+ `max_image_mb: ${resolveMediaUploadLimitBytes("image") / BYTES_PER_MB}`,
529
+ `max_file_mb: ${resolveMediaUploadLimitBytes("file") / BYTES_PER_MB}`,
530
+ `max_voice_mb: ${resolveMediaUploadLimitBytes("voice") / BYTES_PER_MB}`,
531
+ `max_video_mb: ${resolveMediaUploadLimitBytes("video") / BYTES_PER_MB}`,
532
+ `account_id: ${account?.accountId ?? "(none)"}`,
533
+ `user_id: ${account?.userId ?? "(none)"}`,
534
+ `saved_at: ${account?.savedAt ?? "(none)"}`,
535
+ ].join("\n");
536
+ }
537
+ resetSyncState(options = {}) {
538
+ this.clearSyncBuffer();
539
+ this.clearRecentMessages();
540
+ clearInboundMessageClaims();
541
+ if (options.clearContextCache) {
542
+ this.clearContextTokenCache();
543
+ }
544
+ return options.clearContextCache
545
+ ? "Reset sync state and cleared cached context tokens."
546
+ : "Reset sync state.";
547
+ }
548
+ async pollMessages(options = {}) {
549
+ const timeoutMs = options.timeoutMs ?? DEFAULT_LONG_POLL_TIMEOUT_MS;
550
+ const account = this.requireAccount();
551
+ let response = await this.getUpdates(account, timeoutMs);
552
+ if (isWechatSyncSessionTimeout(response) && this.syncBuffer) {
553
+ this.logger.log("WeChat sync session timed out. Clearing local sync cursor and retrying once.");
554
+ this.clearSyncBuffer();
555
+ response = await this.getUpdates(account, timeoutMs);
556
+ }
557
+ if (isWechatSyncSessionTimeout(response)) {
558
+ throw new Error('WeChat session timed out. Run "wechat-setup" to log in again.');
559
+ }
560
+ const isError = (response.ret !== undefined && response.ret !== 0) ||
561
+ (response.errcode !== undefined && response.errcode !== 0);
562
+ if (isError) {
563
+ throw new Error(`getUpdates failed: ret=${response.ret} errcode=${response.errcode} errmsg=${response.errmsg ?? ""}`);
564
+ }
565
+ if (response.get_updates_buf) {
566
+ this.syncBuffer = response.get_updates_buf;
567
+ this.saveSyncBuffer(this.syncBuffer);
568
+ }
569
+ const messages = [];
570
+ let ignoredBacklogCount = 0;
571
+ for (const rawMessage of response.msgs ?? []) {
572
+ if (rawMessage.message_type !== MSG_TYPE_USER) {
573
+ continue;
574
+ }
575
+ const text = extractTextFromMessage(rawMessage);
576
+ if (!text) {
577
+ continue;
578
+ }
579
+ const messageKey = buildMessageKey(rawMessage);
580
+ if (!this.rememberMessage(messageKey)) {
581
+ continue;
582
+ }
583
+ if (!tryClaimInboundMessage(buildScopedMessageClaimKey(account.accountId, messageKey))) {
584
+ continue;
585
+ }
586
+ const senderId = rawMessage.from_user_id ?? "unknown";
587
+ if (rawMessage.context_token) {
588
+ this.cacheContextToken(senderId, rawMessage.context_token);
589
+ }
590
+ const createdAtMs = rawMessage.create_time_ms;
591
+ if (typeof options.minCreatedAtMs === "number" &&
592
+ (!Number.isFinite(createdAtMs) || createdAtMs < options.minCreatedAtMs)) {
593
+ ignoredBacklogCount += 1;
594
+ continue;
595
+ }
596
+ messages.push({
597
+ senderId,
598
+ sender: normalizeSender(senderId),
599
+ sessionId: rawMessage.session_id ?? "",
600
+ text,
601
+ contextToken: rawMessage.context_token,
602
+ createdAt: formatTimestamp(rawMessage.create_time_ms),
603
+ createdAtMs,
604
+ });
605
+ }
606
+ return { messages, ignoredBacklogCount };
607
+ }
608
+ async sendText(senderId, text) {
609
+ const trimmed = text.trim();
610
+ if (!trimmed) {
611
+ return;
612
+ }
613
+ const resolved = this.resolveRecipient(senderId);
614
+ await this.sendTextWithContextToken(resolved.account, resolved.recipientId, trimmed, resolved.contextToken);
615
+ }
616
+ async sendNotification(message, recipientId) {
617
+ const trimmed = message.trim();
618
+ if (!trimmed) {
619
+ throw new Error("Notification text cannot be empty.");
620
+ }
621
+ const resolved = this.resolveRecipient(recipientId);
622
+ await this.sendTextWithContextToken(resolved.account, resolved.recipientId, trimmed, resolved.contextToken);
623
+ return resolved.recipientId;
624
+ }
625
+ async sendImage(imagePath, options = {}) {
626
+ const resolved = this.resolveRecipient(options.recipientId);
627
+ const caption = options.caption?.trim();
628
+ if (caption) {
629
+ await this.sendTextWithContextToken(resolved.account, resolved.recipientId, caption, resolved.contextToken);
630
+ }
631
+ const upload = await this.prepareUpload(resolved.account, resolved.recipientId, imagePath, UPLOAD_MEDIA_TYPE_IMAGE, "image");
632
+ await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
633
+ {
634
+ type: MSG_ITEM_IMAGE,
635
+ image_item: {
636
+ media: {
637
+ encrypt_query_param: upload.downloadParam,
638
+ aes_key: encodeMessageAesKey(upload.aeskey),
639
+ encrypt_type: 1,
640
+ },
641
+ mid_size: upload.filesize,
642
+ },
643
+ },
644
+ ]);
645
+ return resolved.recipientId;
646
+ }
647
+ async sendFile(filePath, options = {}) {
648
+ const resolved = this.resolveRecipient(options.recipientId);
649
+ const upload = await this.prepareUpload(resolved.account, resolved.recipientId, filePath, UPLOAD_MEDIA_TYPE_FILE, "file");
650
+ const fileName = options.title?.trim() || path.basename(filePath);
651
+ await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
652
+ {
653
+ type: MSG_ITEM_FILE,
654
+ file_item: {
655
+ file_name: fileName,
656
+ len: String(upload.rawsize),
657
+ media: {
658
+ encrypt_query_param: upload.downloadParam,
659
+ aes_key: encodeMessageAesKey(upload.aeskey),
660
+ encrypt_type: 1,
661
+ },
662
+ },
663
+ },
664
+ ]);
665
+ return resolved.recipientId;
666
+ }
667
+ async sendVoice(voicePath, recipientId) {
668
+ const resolved = this.resolveRecipient(recipientId);
669
+ const upload = await this.prepareUpload(resolved.account, resolved.recipientId, voicePath, UPLOAD_MEDIA_TYPE_VOICE, "voice");
670
+ await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
671
+ {
672
+ type: MSG_ITEM_VOICE,
673
+ voice_item: {
674
+ media: {
675
+ encrypt_query_param: upload.downloadParam,
676
+ aes_key: encodeMessageAesKey(upload.aeskey),
677
+ encrypt_type: 1,
678
+ },
679
+ },
680
+ },
681
+ ]);
682
+ return resolved.recipientId;
683
+ }
684
+ async sendVideo(videoPath, options = {}) {
685
+ const resolved = this.resolveRecipient(options.recipientId);
686
+ const title = options.title?.trim();
687
+ if (title) {
688
+ await this.sendTextWithContextToken(resolved.account, resolved.recipientId, title, resolved.contextToken);
689
+ }
690
+ const upload = await this.prepareUpload(resolved.account, resolved.recipientId, videoPath, UPLOAD_MEDIA_TYPE_VIDEO, "video");
691
+ await this.sendMessage(resolved.account, resolved.recipientId, resolved.contextToken, [
692
+ {
693
+ type: MSG_ITEM_VIDEO,
694
+ video_item: {
695
+ media: {
696
+ encrypt_query_param: upload.downloadParam,
697
+ aes_key: encodeMessageAesKey(upload.aeskey),
698
+ encrypt_type: 1,
699
+ },
700
+ video_size: upload.filesize,
701
+ },
702
+ },
703
+ ]);
704
+ return resolved.recipientId;
705
+ }
706
+ requireAccount() {
707
+ const account = this.getCredentials();
708
+ if (!account) {
709
+ 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}`);
710
+ }
711
+ return account;
712
+ }
713
+ resolveRecipient(recipientId) {
714
+ const account = this.requireAccount();
715
+ let resolvedRecipientId = recipientId?.trim();
716
+ if (!resolvedRecipientId) {
717
+ const recipients = [...this.contextTokenCache.keys()];
718
+ resolvedRecipientId = recipients[recipients.length - 1];
719
+ if (!resolvedRecipientId) {
720
+ throw new Error("No cached context token is available. Fetch messages first or ask the user to send a new WeChat message.");
721
+ }
722
+ }
723
+ const contextToken = this.contextTokenCache.get(resolvedRecipientId);
724
+ if (!contextToken) {
725
+ throw new Error(`No cached context token for ${resolvedRecipientId}. Fetch messages first or ask the user to send a new WeChat message.`);
726
+ }
727
+ return { account, recipientId: resolvedRecipientId, contextToken };
728
+ }
729
+ async sendTextWithContextToken(account, recipientId, text, contextToken) {
730
+ const trimmed = text.trim();
731
+ if (!trimmed) {
732
+ return;
733
+ }
734
+ await this.sendMessage(account, recipientId, contextToken, [
735
+ { type: MSG_ITEM_TEXT, text_item: { text: trimmed } },
736
+ ]);
737
+ }
738
+ async sendMessage(account, recipientId, contextToken, itemList) {
739
+ await apiFetch({
740
+ baseUrl: account.baseUrl,
741
+ endpoint: "ilink/bot/sendmessage",
742
+ body: JSON.stringify({
743
+ msg: {
744
+ from_user_id: "",
745
+ to_user_id: recipientId,
746
+ client_id: this.generateClientId(),
747
+ message_type: MSG_TYPE_BOT,
748
+ message_state: MSG_STATE_FINISH,
749
+ item_list: itemList,
750
+ context_token: contextToken,
751
+ },
752
+ base_info: { channel_version: CHANNEL_VERSION },
753
+ }),
754
+ token: account.token,
755
+ timeoutMs: SEND_TIMEOUT_MS,
756
+ });
757
+ }
758
+ async prepareUpload(account, recipientId, filePath, mediaType, label) {
759
+ const stat = this.requireExistingFile(filePath);
760
+ assertMediaUploadSizeAllowed(label, stat.size);
761
+ const plaintext = fs.readFileSync(filePath);
762
+ const rawsize = plaintext.length;
763
+ const rawfilemd5 = crypto.createHash("md5").update(plaintext).digest("hex");
764
+ const filesize = aesEcbPaddedSize(rawsize);
765
+ const filekey = crypto.randomBytes(16).toString("hex");
766
+ const aeskey = crypto.randomBytes(16);
767
+ this.logger.log(`Uploading ${label}: ${filePath} (${rawsize} bytes, md5=${rawfilemd5})`);
768
+ const uploadResp = await getUploadUrl(account, {
769
+ filekey,
770
+ media_type: mediaType,
771
+ to_user_id: recipientId,
772
+ rawsize,
773
+ rawfilemd5,
774
+ filesize,
775
+ aeskey: aeskey.toString("hex"),
776
+ });
777
+ if (!uploadResp.upload_param) {
778
+ throw new Error("getUploadUrl returned no upload_param");
779
+ }
780
+ const { downloadParam } = await uploadBufferToCdn({
781
+ buf: plaintext,
782
+ uploadParam: uploadResp.upload_param,
783
+ filekey,
784
+ aeskey,
785
+ onRetry: (attempt) => {
786
+ this.logger.log(`CDN upload attempt ${attempt} failed for ${label}, retrying...`);
787
+ },
788
+ });
789
+ this.logger.log(`${label} upload complete, downloadParam length=${downloadParam.length}`);
790
+ return {
791
+ rawsize,
792
+ filesize,
793
+ aeskey,
794
+ downloadParam,
795
+ };
796
+ }
797
+ requireExistingFile(filePath) {
798
+ let stat;
799
+ try {
800
+ stat = fs.statSync(filePath);
801
+ }
802
+ catch {
803
+ throw new Error(`File not found: ${filePath}`);
804
+ }
805
+ if (!stat.isFile()) {
806
+ throw new Error(`Not a file: ${filePath}`);
807
+ }
808
+ return stat;
809
+ }
810
+ async getUpdates(account, timeoutMs) {
811
+ try {
812
+ const raw = await apiFetch({
813
+ baseUrl: account.baseUrl,
814
+ endpoint: "ilink/bot/getupdates",
815
+ body: JSON.stringify({
816
+ get_updates_buf: this.syncBuffer,
817
+ base_info: { channel_version: CHANNEL_VERSION },
818
+ }),
819
+ token: account.token,
820
+ timeoutMs,
821
+ });
822
+ return JSON.parse(raw);
823
+ }
824
+ catch (err) {
825
+ if (err instanceof Error && err.name === "AbortError") {
826
+ return { ret: 0, msgs: [], get_updates_buf: this.syncBuffer };
827
+ }
828
+ throw err;
829
+ }
830
+ }
831
+ rememberMessage(key) {
832
+ if (!key || this.recentMessageKeys.has(key)) {
833
+ return false;
834
+ }
835
+ this.recentMessageKeys.add(key);
836
+ this.recentMessageOrder.push(key);
837
+ while (this.recentMessageOrder.length > RECENT_MESSAGE_CACHE_SIZE) {
838
+ const oldest = this.recentMessageOrder.shift();
839
+ if (oldest) {
840
+ this.recentMessageKeys.delete(oldest);
841
+ }
842
+ }
843
+ return true;
844
+ }
845
+ clearRecentMessages() {
846
+ this.recentMessageKeys.clear();
847
+ this.recentMessageOrder.length = 0;
848
+ }
849
+ readSyncBuffer() {
850
+ try {
851
+ if (!fs.existsSync(SYNC_BUF_FILE)) {
852
+ return "";
853
+ }
854
+ return fs.readFileSync(SYNC_BUF_FILE, "utf-8");
855
+ }
856
+ catch (err) {
857
+ this.logger.logError(`Failed to read sync state: ${String(err)}`);
858
+ return "";
859
+ }
860
+ }
861
+ saveSyncBuffer(syncBuffer) {
862
+ ensureChannelDataDir();
863
+ fs.writeFileSync(SYNC_BUF_FILE, syncBuffer, "utf-8");
864
+ }
865
+ clearSyncBuffer() {
866
+ this.syncBuffer = "";
867
+ if (fs.existsSync(SYNC_BUF_FILE)) {
868
+ fs.rmSync(SYNC_BUF_FILE, { force: true });
869
+ }
870
+ }
871
+ cacheContextToken(senderId, token) {
872
+ if (this.contextTokenCache.has(senderId)) {
873
+ this.contextTokenCache.delete(senderId);
874
+ }
875
+ this.contextTokenCache.set(senderId, token);
876
+ writeJsonFile(CONTEXT_CACHE_FILE, Object.fromEntries(this.contextTokenCache));
877
+ }
878
+ clearContextTokenCache() {
879
+ this.contextTokenCache.clear();
880
+ if (fs.existsSync(CONTEXT_CACHE_FILE)) {
881
+ fs.rmSync(CONTEXT_CACHE_FILE, { force: true });
882
+ }
883
+ }
884
+ generateClientId() {
885
+ return `wechat-bridge:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
886
+ }
887
+ }