@unlinearity/cli-wechat-bridge 1.0.3 → 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.
- package/README.md +325 -221
- package/bin/wechat-daemon.mjs +5 -0
- package/dist/bridge/bridge-adapter-common.js +95 -0
- package/dist/bridge/bridge-adapters.claude.js +1 -1
- package/dist/bridge/bridge-adapters.codex.js +18 -3
- package/dist/bridge/bridge-adapters.core.js +51 -7
- package/dist/bridge/bridge-adapters.opencode.js +220 -22
- package/dist/bridge/bridge-adapters.shared.js +4 -97
- package/dist/bridge/bridge-controller.js +6 -2
- package/dist/bridge/bridge-final-reply.js +18 -3
- package/dist/bridge/bridge-state.js +9 -3
- package/dist/bridge/bridge-utils.js +81 -14
- package/dist/bridge/claude-hooks.js +2 -2
- package/dist/bridge/wechat-bridge.js +139 -31
- package/dist/companion/codex-remote-client.js +11 -4
- package/dist/companion/local-companion-link.js +56 -26
- package/dist/companion/local-companion-start.js +54 -6
- package/dist/companion/local-companion.js +6 -1
- package/dist/daemon/daemon-link.js +175 -0
- package/dist/daemon/wechat-daemon.js +1202 -0
- package/dist/utils/version-checker.js +1 -1
- package/dist/wechat/channel-config.js +126 -33
- package/dist/wechat/standalone-bot.js +5 -6
- package/dist/wechat/wechat-channel.js +21 -10
- package/dist/wechat/wechat-transport.js +336 -10
- package/package.json +12 -2
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import { createCipheriv } from "node:crypto";
|
|
2
|
+
import { createCipheriv, createDecipheriv } from "node:crypto";
|
|
3
3
|
import fs from "node:fs";
|
|
4
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";
|
|
5
|
+
import { CONTEXT_CACHE_FILE, CREDENTIALS_FILE, ensureChannelDataDir, INBOUND_ATTACHMENTS_DIR, INBOUND_MESSAGE_CLAIMS_DIR, migrateLegacyChannelFiles, SYNC_BUF_FILE, } from "./channel-config.js";
|
|
6
6
|
export const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
|
|
7
7
|
const CDN_BASE_URL = "https://novac2c.cdn.weixin.qq.com/c2c";
|
|
8
8
|
const CHANNEL_VERSION = "0.3.0";
|
|
9
9
|
const RECENT_MESSAGE_CACHE_SIZE = 500;
|
|
10
10
|
const BYTES_PER_MB = 1024 * 1024;
|
|
11
11
|
const SEND_TIMEOUT_MS = 15_000;
|
|
12
|
+
const INBOUND_DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
12
13
|
const CDN_MAX_RETRIES = 3;
|
|
13
14
|
const ERROR_CAUSE_DEPTH_LIMIT = 4;
|
|
14
15
|
const INBOUND_MESSAGE_CLAIM_TTL_MS = 10 * 60 * 1000;
|
|
@@ -29,6 +30,26 @@ export function isWechatSyncSessionTimeout(response) {
|
|
|
29
30
|
return (response.errcode === SYNC_SESSION_TIMEOUT_ERRCODE &&
|
|
30
31
|
/session timeout/i.test(response.errmsg ?? ""));
|
|
31
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
|
+
}
|
|
32
53
|
const DEFAULT_MEDIA_UPLOAD_LIMIT_MB = {
|
|
33
54
|
image: 20,
|
|
34
55
|
file: 50,
|
|
@@ -41,6 +62,14 @@ const MEDIA_UPLOAD_LIMIT_ENV_KEYS = {
|
|
|
41
62
|
voice: "WECHAT_MAX_VOICE_MB",
|
|
42
63
|
video: "WECHAT_MAX_VIDEO_MB",
|
|
43
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
|
+
};
|
|
44
73
|
const RETRYABLE_HTTP_STATUS_CODES = new Set([408, 425, 429]);
|
|
45
74
|
const RETRYABLE_NETWORK_ERROR_CODES = new Set([
|
|
46
75
|
"ECONNABORTED",
|
|
@@ -88,6 +117,49 @@ function readJsonFile(filePath) {
|
|
|
88
117
|
function isRecord(value) {
|
|
89
118
|
return typeof value === "object" && value !== null;
|
|
90
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
|
+
}
|
|
91
163
|
function describeErrorNode(value) {
|
|
92
164
|
if (value instanceof Error) {
|
|
93
165
|
const error = value;
|
|
@@ -282,9 +354,34 @@ function encryptAesEcb(plaintext, key) {
|
|
|
282
354
|
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
283
355
|
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
284
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
|
+
}
|
|
285
361
|
function aesEcbPaddedSize(plaintextSize) {
|
|
286
362
|
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
287
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
|
+
}
|
|
288
385
|
export function formatByteSize(bytes) {
|
|
289
386
|
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
290
387
|
return "0 B";
|
|
@@ -307,6 +404,14 @@ export function resolveMediaUploadLimitBytes(label, env = process.env) {
|
|
|
307
404
|
const limitMb = Number.isFinite(parsedMb) && parsedMb > 0 ? parsedMb : fallbackMb;
|
|
308
405
|
return Math.floor(limitMb * BYTES_PER_MB);
|
|
309
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
|
+
}
|
|
310
415
|
export function assertMediaUploadSizeAllowed(label, rawsize, env = process.env) {
|
|
311
416
|
const limitBytes = resolveMediaUploadLimitBytes(label, env);
|
|
312
417
|
if (rawsize <= limitBytes) {
|
|
@@ -316,6 +421,15 @@ export function assertMediaUploadSizeAllowed(label, rawsize, env = process.env)
|
|
|
316
421
|
const labelName = label.charAt(0).toUpperCase() + label.slice(1);
|
|
317
422
|
throw new Error(`${labelName} too large: ${formatByteSize(rawsize)} exceeds ${formatByteSize(limitBytes)} limit. Set ${envKey} to override.`);
|
|
318
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
|
+
}
|
|
319
433
|
function encodeMessageAesKey(aeskey) {
|
|
320
434
|
return Buffer.from(aeskey.toString("hex")).toString("base64");
|
|
321
435
|
}
|
|
@@ -336,6 +450,32 @@ async function getUploadUrl(account, params) {
|
|
|
336
450
|
function buildCdnUploadUrl(cdnBaseUrl, uploadParam, filekey) {
|
|
337
451
|
return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
|
|
338
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
|
+
}
|
|
339
479
|
async function uploadBufferToCdn(params) {
|
|
340
480
|
const ciphertext = encryptAesEcb(params.buf, params.aeskey);
|
|
341
481
|
const cdnUrl = buildCdnUploadUrl(CDN_BASE_URL, params.uploadParam, params.filekey);
|
|
@@ -378,6 +518,47 @@ async function uploadBufferToCdn(params) {
|
|
|
378
518
|
}
|
|
379
519
|
return { downloadParam };
|
|
380
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
|
+
}
|
|
381
562
|
function extractReferenceLabel(item) {
|
|
382
563
|
const ref = item.ref_msg;
|
|
383
564
|
if (!ref) {
|
|
@@ -393,11 +574,53 @@ function extractReferenceLabel(item) {
|
|
|
393
574
|
}
|
|
394
575
|
return parts.length ? `Quoted: ${parts.join(" | ")}` : null;
|
|
395
576
|
}
|
|
396
|
-
function
|
|
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) {
|
|
397
619
|
if (!message.item_list?.length) {
|
|
398
|
-
return "";
|
|
620
|
+
return { text: "", attachments: [] };
|
|
399
621
|
}
|
|
400
622
|
const lines = [];
|
|
623
|
+
const attachments = [];
|
|
401
624
|
for (const item of message.item_list) {
|
|
402
625
|
const reference = extractReferenceLabel(item);
|
|
403
626
|
if (reference && !lines.includes(reference)) {
|
|
@@ -415,8 +638,29 @@ function extractTextFromMessage(message) {
|
|
|
415
638
|
lines.push(transcript);
|
|
416
639
|
}
|
|
417
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
|
+
}
|
|
418
659
|
}
|
|
419
|
-
return
|
|
660
|
+
return {
|
|
661
|
+
text: lines.join("\n").trim(),
|
|
662
|
+
attachments,
|
|
663
|
+
};
|
|
420
664
|
}
|
|
421
665
|
function buildMessageKey(message) {
|
|
422
666
|
return [
|
|
@@ -499,15 +743,42 @@ function formatTimestamp(timestampMs) {
|
|
|
499
743
|
}
|
|
500
744
|
return new Date(timestampMs).toISOString();
|
|
501
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
|
+
}
|
|
502
772
|
export class WeChatTransport {
|
|
503
773
|
logger;
|
|
504
774
|
recentMessageKeys = new Set();
|
|
505
775
|
recentMessageOrder = [];
|
|
506
|
-
contextTokenCache
|
|
776
|
+
contextTokenCache;
|
|
507
777
|
syncBuffer = "";
|
|
508
778
|
constructor(logger) {
|
|
509
779
|
this.logger = logger;
|
|
510
780
|
migrateLegacyChannelFiles((message) => this.logger.log(message));
|
|
781
|
+
this.contextTokenCache = new Map(Object.entries(readJsonFile(CONTEXT_CACHE_FILE) ?? {}));
|
|
511
782
|
this.syncBuffer = this.readSyncBuffer();
|
|
512
783
|
}
|
|
513
784
|
getCredentials() {
|
|
@@ -529,6 +800,8 @@ export class WeChatTransport {
|
|
|
529
800
|
`max_file_mb: ${resolveMediaUploadLimitBytes("file") / BYTES_PER_MB}`,
|
|
530
801
|
`max_voice_mb: ${resolveMediaUploadLimitBytes("voice") / BYTES_PER_MB}`,
|
|
531
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}`,
|
|
532
805
|
`account_id: ${account?.accountId ?? "(none)"}`,
|
|
533
806
|
`user_id: ${account?.userId ?? "(none)"}`,
|
|
534
807
|
`saved_at: ${account?.savedAt ?? "(none)"}`,
|
|
@@ -572,8 +845,8 @@ export class WeChatTransport {
|
|
|
572
845
|
if (rawMessage.message_type !== MSG_TYPE_USER) {
|
|
573
846
|
continue;
|
|
574
847
|
}
|
|
575
|
-
const
|
|
576
|
-
if (!text) {
|
|
848
|
+
const extracted = extractInboundMessageContent(rawMessage);
|
|
849
|
+
if (!extracted.text && extracted.attachments.length === 0) {
|
|
577
850
|
continue;
|
|
578
851
|
}
|
|
579
852
|
const messageKey = buildMessageKey(rawMessage);
|
|
@@ -587,17 +860,20 @@ export class WeChatTransport {
|
|
|
587
860
|
if (rawMessage.context_token) {
|
|
588
861
|
this.cacheContextToken(senderId, rawMessage.context_token);
|
|
589
862
|
}
|
|
590
|
-
const createdAtMs = rawMessage.create_time_ms;
|
|
863
|
+
const createdAtMs = rawMessage.create_time_ms ?? 0;
|
|
591
864
|
if (typeof options.minCreatedAtMs === "number" &&
|
|
592
865
|
(!Number.isFinite(createdAtMs) || createdAtMs < options.minCreatedAtMs)) {
|
|
593
866
|
ignoredBacklogCount += 1;
|
|
594
867
|
continue;
|
|
595
868
|
}
|
|
869
|
+
const { attachments, failureLines } = await this.downloadInboundAttachments(extracted.attachments, rawMessage);
|
|
870
|
+
const text = appendInboundAttachmentFailureText(extracted.text, failureLines);
|
|
596
871
|
messages.push({
|
|
597
872
|
senderId,
|
|
598
873
|
sender: normalizeSender(senderId),
|
|
599
874
|
sessionId: rawMessage.session_id ?? "",
|
|
600
875
|
text,
|
|
876
|
+
attachments,
|
|
601
877
|
contextToken: rawMessage.context_token,
|
|
602
878
|
createdAt: formatTimestamp(rawMessage.create_time_ms),
|
|
603
879
|
createdAtMs,
|
|
@@ -605,6 +881,46 @@ export class WeChatTransport {
|
|
|
605
881
|
}
|
|
606
882
|
return { messages, ignoredBacklogCount };
|
|
607
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
|
+
}
|
|
608
924
|
async sendText(senderId, text) {
|
|
609
925
|
const trimmed = text.trim();
|
|
610
926
|
if (!trimmed) {
|
|
@@ -736,7 +1052,7 @@ export class WeChatTransport {
|
|
|
736
1052
|
]);
|
|
737
1053
|
}
|
|
738
1054
|
async sendMessage(account, recipientId, contextToken, itemList) {
|
|
739
|
-
await apiFetch({
|
|
1055
|
+
const raw = await apiFetch({
|
|
740
1056
|
baseUrl: account.baseUrl,
|
|
741
1057
|
endpoint: "ilink/bot/sendmessage",
|
|
742
1058
|
body: JSON.stringify({
|
|
@@ -754,6 +1070,7 @@ export class WeChatTransport {
|
|
|
754
1070
|
token: account.token,
|
|
755
1071
|
timeoutMs: SEND_TIMEOUT_MS,
|
|
756
1072
|
});
|
|
1073
|
+
assertWechatApiResponseOk("sendmessage", raw);
|
|
757
1074
|
}
|
|
758
1075
|
async prepareUpload(account, recipientId, filePath, mediaType, label) {
|
|
759
1076
|
const stat = this.requireExistingFile(filePath);
|
|
@@ -881,6 +1198,15 @@ export class WeChatTransport {
|
|
|
881
1198
|
fs.rmSync(CONTEXT_CACHE_FILE, { force: true });
|
|
882
1199
|
}
|
|
883
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
|
+
}
|
|
884
1210
|
generateClientId() {
|
|
885
1211
|
return `wechat-bridge:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
886
1212
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unlinearity/cli-wechat-bridge",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.5",
|
|
4
4
|
"description": "CLI WeChat Bridge for Codex, Claude Code, OpenCode, and PowerShell over WeChat",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"wechat-bridge-opencode": "bin/wechat-bridge-opencode.mjs",
|
|
44
44
|
"wechat-opencode": "bin/wechat-opencode.mjs",
|
|
45
45
|
"wechat-opencode-start": "bin/wechat-opencode-start.mjs",
|
|
46
|
+
"wechat-daemon": "bin/wechat-daemon.mjs",
|
|
46
47
|
"wechat-setup": "bin/wechat-setup.mjs",
|
|
47
48
|
"wechat-check-update": "bin/wechat-check-update.mjs"
|
|
48
49
|
},
|
|
@@ -50,10 +51,16 @@
|
|
|
50
51
|
"clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
|
|
51
52
|
"build": "npm run clean && tsc -p tsconfig.build.json",
|
|
52
53
|
"prepack": "npm run build",
|
|
54
|
+
"lint": "eslint bin src test",
|
|
55
|
+
"lint:fix": "eslint bin src test --fix",
|
|
56
|
+
"typecheck:src": "tsc -p tsconfig.typecheck.src.json --noEmit --pretty false",
|
|
57
|
+
"quality": "npm run lint && npm run typecheck:src && bun test test && npm run build",
|
|
58
|
+
"smoke:global": "node scripts/smoke-global-install.mjs",
|
|
53
59
|
"setup": "node --no-warnings --experimental-strip-types src/wechat/setup.ts",
|
|
54
60
|
"start": "node --no-warnings --experimental-strip-types src/wechat/wechat-channel.ts",
|
|
55
61
|
"check": "node --no-warnings --experimental-strip-types src/wechat/wechat-channel.ts --check",
|
|
56
62
|
"bridge": "node --no-warnings --experimental-strip-types src/bridge/wechat-bridge.ts",
|
|
63
|
+
"daemon": "node --no-warnings --experimental-strip-types src/daemon/wechat-daemon.ts",
|
|
57
64
|
"bridge:codex": "node --no-warnings --experimental-strip-types src/bridge/wechat-bridge.ts --adapter codex",
|
|
58
65
|
"codex:panel": "node --no-warnings --experimental-strip-types src/companion/codex-remote-client.ts",
|
|
59
66
|
"codex:start": "node --no-warnings --experimental-strip-types src/companion/local-companion-start.ts",
|
|
@@ -80,8 +87,11 @@
|
|
|
80
87
|
"qrcode-terminal": "^0.12.0"
|
|
81
88
|
},
|
|
82
89
|
"devDependencies": {
|
|
90
|
+
"@eslint/js": "^10.0.1",
|
|
83
91
|
"@types/bun": "latest",
|
|
84
|
-
"
|
|
92
|
+
"eslint": "^10.3.0",
|
|
93
|
+
"typescript": "^6.0.2",
|
|
94
|
+
"typescript-eslint": "^8.59.3"
|
|
85
95
|
},
|
|
86
96
|
"engines": {
|
|
87
97
|
"bun": ">=1.0.0",
|