@unlinearity/cli-wechat-bridge 1.0.4 → 1.0.6
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 +136 -25
- package/bin/wechat-daemon.mjs +5 -0
- package/dist/bridge/bridge-adapters.claude.js +33 -3
- package/dist/bridge/bridge-adapters.codex.js +216 -6
- package/dist/bridge/bridge-adapters.core.js +46 -6
- package/dist/bridge/bridge-adapters.opencode.js +79 -13
- package/dist/bridge/bridge-adapters.shared.js +215 -1
- package/dist/bridge/bridge-controller.js +6 -2
- package/dist/bridge/bridge-utils.js +92 -9
- package/dist/bridge/claude-hooks.js +64 -3
- package/dist/bridge/wechat-bridge.js +102 -8
- package/dist/companion/codex-remote-client.js +8 -4
- package/dist/companion/local-companion-link.js +56 -26
- package/dist/companion/local-companion-start.js +48 -5
- package/dist/companion/local-companion.js +3 -1
- package/dist/daemon/daemon-link.js +175 -0
- package/dist/daemon/wechat-daemon.js +1202 -0
- 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 +291 -9
- package/package.json +62 -98
|
@@ -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",
|
|
@@ -129,7 +158,7 @@ export function assertWechatApiResponseOk(endpoint, raw) {
|
|
|
129
158
|
readStringResponseField(response, "message") ??
|
|
130
159
|
readStringResponseField(response, "msg") ??
|
|
131
160
|
"";
|
|
132
|
-
throw new
|
|
161
|
+
throw new WechatApiResponseError({ endpoint, ret, errcode, errmsg });
|
|
133
162
|
}
|
|
134
163
|
function describeErrorNode(value) {
|
|
135
164
|
if (value instanceof Error) {
|
|
@@ -325,9 +354,34 @@ function encryptAesEcb(plaintext, key) {
|
|
|
325
354
|
const cipher = createCipheriv("aes-128-ecb", key, null);
|
|
326
355
|
return Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
|
327
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
|
+
}
|
|
328
361
|
function aesEcbPaddedSize(plaintextSize) {
|
|
329
362
|
return Math.ceil((plaintextSize + 1) / 16) * 16;
|
|
330
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
|
+
}
|
|
331
385
|
export function formatByteSize(bytes) {
|
|
332
386
|
if (!Number.isFinite(bytes) || bytes < 0) {
|
|
333
387
|
return "0 B";
|
|
@@ -350,6 +404,14 @@ export function resolveMediaUploadLimitBytes(label, env = process.env) {
|
|
|
350
404
|
const limitMb = Number.isFinite(parsedMb) && parsedMb > 0 ? parsedMb : fallbackMb;
|
|
351
405
|
return Math.floor(limitMb * BYTES_PER_MB);
|
|
352
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
|
+
}
|
|
353
415
|
export function assertMediaUploadSizeAllowed(label, rawsize, env = process.env) {
|
|
354
416
|
const limitBytes = resolveMediaUploadLimitBytes(label, env);
|
|
355
417
|
if (rawsize <= limitBytes) {
|
|
@@ -359,6 +421,15 @@ export function assertMediaUploadSizeAllowed(label, rawsize, env = process.env)
|
|
|
359
421
|
const labelName = label.charAt(0).toUpperCase() + label.slice(1);
|
|
360
422
|
throw new Error(`${labelName} too large: ${formatByteSize(rawsize)} exceeds ${formatByteSize(limitBytes)} limit. Set ${envKey} to override.`);
|
|
361
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
|
+
}
|
|
362
433
|
function encodeMessageAesKey(aeskey) {
|
|
363
434
|
return Buffer.from(aeskey.toString("hex")).toString("base64");
|
|
364
435
|
}
|
|
@@ -379,6 +450,32 @@ async function getUploadUrl(account, params) {
|
|
|
379
450
|
function buildCdnUploadUrl(cdnBaseUrl, uploadParam, filekey) {
|
|
380
451
|
return `${cdnBaseUrl}/upload?encrypted_query_param=${encodeURIComponent(uploadParam)}&filekey=${encodeURIComponent(filekey)}`;
|
|
381
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
|
+
}
|
|
382
479
|
async function uploadBufferToCdn(params) {
|
|
383
480
|
const ciphertext = encryptAesEcb(params.buf, params.aeskey);
|
|
384
481
|
const cdnUrl = buildCdnUploadUrl(CDN_BASE_URL, params.uploadParam, params.filekey);
|
|
@@ -421,6 +518,47 @@ async function uploadBufferToCdn(params) {
|
|
|
421
518
|
}
|
|
422
519
|
return { downloadParam };
|
|
423
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
|
+
}
|
|
424
562
|
function extractReferenceLabel(item) {
|
|
425
563
|
const ref = item.ref_msg;
|
|
426
564
|
if (!ref) {
|
|
@@ -436,11 +574,53 @@ function extractReferenceLabel(item) {
|
|
|
436
574
|
}
|
|
437
575
|
return parts.length ? `Quoted: ${parts.join(" | ")}` : null;
|
|
438
576
|
}
|
|
439
|
-
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) {
|
|
440
619
|
if (!message.item_list?.length) {
|
|
441
|
-
return "";
|
|
620
|
+
return { text: "", attachments: [] };
|
|
442
621
|
}
|
|
443
622
|
const lines = [];
|
|
623
|
+
const attachments = [];
|
|
444
624
|
for (const item of message.item_list) {
|
|
445
625
|
const reference = extractReferenceLabel(item);
|
|
446
626
|
if (reference && !lines.includes(reference)) {
|
|
@@ -458,8 +638,29 @@ function extractTextFromMessage(message) {
|
|
|
458
638
|
lines.push(transcript);
|
|
459
639
|
}
|
|
460
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
|
+
}
|
|
461
659
|
}
|
|
462
|
-
return
|
|
660
|
+
return {
|
|
661
|
+
text: lines.join("\n").trim(),
|
|
662
|
+
attachments,
|
|
663
|
+
};
|
|
463
664
|
}
|
|
464
665
|
function buildMessageKey(message) {
|
|
465
666
|
return [
|
|
@@ -542,15 +743,42 @@ function formatTimestamp(timestampMs) {
|
|
|
542
743
|
}
|
|
543
744
|
return new Date(timestampMs).toISOString();
|
|
544
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
|
+
}
|
|
545
772
|
export class WeChatTransport {
|
|
546
773
|
logger;
|
|
547
774
|
recentMessageKeys = new Set();
|
|
548
775
|
recentMessageOrder = [];
|
|
549
|
-
contextTokenCache
|
|
776
|
+
contextTokenCache;
|
|
550
777
|
syncBuffer = "";
|
|
551
778
|
constructor(logger) {
|
|
552
779
|
this.logger = logger;
|
|
553
780
|
migrateLegacyChannelFiles((message) => this.logger.log(message));
|
|
781
|
+
this.contextTokenCache = new Map(Object.entries(readJsonFile(CONTEXT_CACHE_FILE) ?? {}));
|
|
554
782
|
this.syncBuffer = this.readSyncBuffer();
|
|
555
783
|
}
|
|
556
784
|
getCredentials() {
|
|
@@ -572,6 +800,8 @@ export class WeChatTransport {
|
|
|
572
800
|
`max_file_mb: ${resolveMediaUploadLimitBytes("file") / BYTES_PER_MB}`,
|
|
573
801
|
`max_voice_mb: ${resolveMediaUploadLimitBytes("voice") / BYTES_PER_MB}`,
|
|
574
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}`,
|
|
575
805
|
`account_id: ${account?.accountId ?? "(none)"}`,
|
|
576
806
|
`user_id: ${account?.userId ?? "(none)"}`,
|
|
577
807
|
`saved_at: ${account?.savedAt ?? "(none)"}`,
|
|
@@ -615,8 +845,8 @@ export class WeChatTransport {
|
|
|
615
845
|
if (rawMessage.message_type !== MSG_TYPE_USER) {
|
|
616
846
|
continue;
|
|
617
847
|
}
|
|
618
|
-
const
|
|
619
|
-
if (!text) {
|
|
848
|
+
const extracted = extractInboundMessageContent(rawMessage);
|
|
849
|
+
if (!extracted.text && extracted.attachments.length === 0) {
|
|
620
850
|
continue;
|
|
621
851
|
}
|
|
622
852
|
const messageKey = buildMessageKey(rawMessage);
|
|
@@ -636,11 +866,14 @@ export class WeChatTransport {
|
|
|
636
866
|
ignoredBacklogCount += 1;
|
|
637
867
|
continue;
|
|
638
868
|
}
|
|
869
|
+
const { attachments, failureLines } = await this.downloadInboundAttachments(extracted.attachments, rawMessage);
|
|
870
|
+
const text = appendInboundAttachmentFailureText(extracted.text, failureLines);
|
|
639
871
|
messages.push({
|
|
640
872
|
senderId,
|
|
641
873
|
sender: normalizeSender(senderId),
|
|
642
874
|
sessionId: rawMessage.session_id ?? "",
|
|
643
875
|
text,
|
|
876
|
+
attachments,
|
|
644
877
|
contextToken: rawMessage.context_token,
|
|
645
878
|
createdAt: formatTimestamp(rawMessage.create_time_ms),
|
|
646
879
|
createdAtMs,
|
|
@@ -648,6 +881,46 @@ export class WeChatTransport {
|
|
|
648
881
|
}
|
|
649
882
|
return { messages, ignoredBacklogCount };
|
|
650
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
|
+
}
|
|
651
924
|
async sendText(senderId, text) {
|
|
652
925
|
const trimmed = text.trim();
|
|
653
926
|
if (!trimmed) {
|
|
@@ -925,6 +1198,15 @@ export class WeChatTransport {
|
|
|
925
1198
|
fs.rmSync(CONTEXT_CACHE_FILE, { force: true });
|
|
926
1199
|
}
|
|
927
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
|
+
}
|
|
928
1210
|
generateClientId() {
|
|
929
1211
|
return `wechat-bridge:${Date.now()}-${crypto.randomBytes(4).toString("hex")}`;
|
|
930
1212
|
}
|
package/package.json
CHANGED
|
@@ -1,98 +1,62 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@unlinearity/cli-wechat-bridge",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "CLI WeChat Bridge for Codex, Claude Code, OpenCode, and PowerShell over WeChat",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"files": [
|
|
7
|
-
"bin/",
|
|
8
|
-
"dist/",
|
|
9
|
-
"README.md",
|
|
10
|
-
"LICENSE.txt"
|
|
11
|
-
],
|
|
12
|
-
"keywords": [
|
|
13
|
-
"wechat",
|
|
14
|
-
"cli",
|
|
15
|
-
"bridge",
|
|
16
|
-
"codex",
|
|
17
|
-
"claude",
|
|
18
|
-
"opencode",
|
|
19
|
-
"terminal"
|
|
20
|
-
],
|
|
21
|
-
"author": "UNLINEARITY",
|
|
22
|
-
"license": "MIT",
|
|
23
|
-
"repository": {
|
|
24
|
-
"type": "git",
|
|
25
|
-
"url": "git+https://github.com/UNLINEARITY/CLI-WeChat-Bridge.git"
|
|
26
|
-
},
|
|
27
|
-
"bugs": {
|
|
28
|
-
"url": "https://github.com/UNLINEARITY/CLI-WeChat-Bridge/issues"
|
|
29
|
-
},
|
|
30
|
-
"homepage": "https://github.com/UNLINEARITY/CLI-WeChat-Bridge#readme",
|
|
31
|
-
"publishConfig": {
|
|
32
|
-
"access": "public"
|
|
33
|
-
},
|
|
34
|
-
"bin": {
|
|
35
|
-
"wechat-bridge": "bin/wechat-bridge.mjs",
|
|
36
|
-
"wechat-bridge-codex": "bin/wechat-bridge-codex.mjs",
|
|
37
|
-
"wechat-codex": "bin/wechat-codex.mjs",
|
|
38
|
-
"wechat-codex-start": "bin/wechat-codex-start.mjs",
|
|
39
|
-
"wechat-claude": "bin/wechat-claude.mjs",
|
|
40
|
-
"wechat-claude-start": "bin/wechat-claude-start.mjs",
|
|
41
|
-
"wechat-bridge-claude": "bin/wechat-bridge-claude.mjs",
|
|
42
|
-
"wechat-bridge-shell": "bin/wechat-bridge-shell.mjs",
|
|
43
|
-
"wechat-bridge-opencode": "bin/wechat-bridge-opencode.mjs",
|
|
44
|
-
"wechat-opencode": "bin/wechat-opencode.mjs",
|
|
45
|
-
"wechat-opencode-start": "bin/wechat-opencode-start.mjs",
|
|
46
|
-
"wechat-
|
|
47
|
-
"wechat-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"codex:panel": "node --no-warnings --experimental-strip-types src/companion/codex-remote-client.ts",
|
|
64
|
-
"codex:start": "node --no-warnings --experimental-strip-types src/companion/local-companion-start.ts",
|
|
65
|
-
"claude:start": "node --no-warnings --experimental-strip-types src/companion/local-companion-start.ts --adapter claude",
|
|
66
|
-
"claude:companion": "node --no-warnings --experimental-strip-types src/companion/local-companion.ts --adapter claude",
|
|
67
|
-
"bridge:claude": "node --no-warnings --experimental-strip-types src/bridge/wechat-bridge.ts --adapter claude",
|
|
68
|
-
"bridge:shell": "node --no-warnings --experimental-strip-types src/bridge/wechat-bridge.ts --adapter shell",
|
|
69
|
-
"bridge:opencode": "node --no-warnings --experimental-strip-types src/bridge/wechat-bridge.ts --adapter opencode",
|
|
70
|
-
"opencode:panel": "node --no-warnings --experimental-strip-types src/companion/local-companion.ts --adapter opencode",
|
|
71
|
-
"opencode:start": "node --no-warnings --experimental-strip-types src/companion/local-companion-start.ts --adapter opencode",
|
|
72
|
-
"bridge:bun": "bun src/bridge/wechat-bridge.ts",
|
|
73
|
-
"test": "bun test test",
|
|
74
|
-
"test:bridge": "bun test test/bridge",
|
|
75
|
-
"test:companion": "bun test test/companion",
|
|
76
|
-
"test:wechat": "bun test test/wechat",
|
|
77
|
-
"test:watch": "bun test --watch test"
|
|
78
|
-
},
|
|
79
|
-
"dependencies": {
|
|
80
|
-
"@anthropic-ai/sdk": "^0.80.0",
|
|
81
|
-
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
82
|
-
"@opencode-ai/sdk": "^1.3.2",
|
|
83
|
-
"ajv-formats": "^3.0.1",
|
|
84
|
-
"node-pty": "^1.1.0",
|
|
85
|
-
"qrcode-terminal": "^0.12.0"
|
|
86
|
-
},
|
|
87
|
-
"devDependencies": {
|
|
88
|
-
"@eslint/js": "^10.0.1",
|
|
89
|
-
"@types/bun": "latest",
|
|
90
|
-
"eslint": "^10.3.0",
|
|
91
|
-
"typescript": "^6.0.2",
|
|
92
|
-
"typescript-eslint": "^8.59.3"
|
|
93
|
-
},
|
|
94
|
-
"engines": {
|
|
95
|
-
"bun": ">=1.0.0",
|
|
96
|
-
"node": ">=24.0.0"
|
|
97
|
-
}
|
|
98
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@unlinearity/cli-wechat-bridge",
|
|
3
|
+
"version": "1.0.6",
|
|
4
|
+
"description": "CLI WeChat Bridge for Codex, Claude Code, OpenCode, and PowerShell over WeChat",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"bin/",
|
|
8
|
+
"dist/",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE.txt"
|
|
11
|
+
],
|
|
12
|
+
"keywords": [
|
|
13
|
+
"wechat",
|
|
14
|
+
"cli",
|
|
15
|
+
"bridge",
|
|
16
|
+
"codex",
|
|
17
|
+
"claude",
|
|
18
|
+
"opencode",
|
|
19
|
+
"terminal"
|
|
20
|
+
],
|
|
21
|
+
"author": "UNLINEARITY",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/UNLINEARITY/CLI-WeChat-Bridge.git"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/UNLINEARITY/CLI-WeChat-Bridge/issues"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/UNLINEARITY/CLI-WeChat-Bridge#readme",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"wechat-bridge": "bin/wechat-bridge.mjs",
|
|
36
|
+
"wechat-bridge-codex": "bin/wechat-bridge-codex.mjs",
|
|
37
|
+
"wechat-codex": "bin/wechat-codex.mjs",
|
|
38
|
+
"wechat-codex-start": "bin/wechat-codex-start.mjs",
|
|
39
|
+
"wechat-claude": "bin/wechat-claude.mjs",
|
|
40
|
+
"wechat-claude-start": "bin/wechat-claude-start.mjs",
|
|
41
|
+
"wechat-bridge-claude": "bin/wechat-bridge-claude.mjs",
|
|
42
|
+
"wechat-bridge-shell": "bin/wechat-bridge-shell.mjs",
|
|
43
|
+
"wechat-bridge-opencode": "bin/wechat-bridge-opencode.mjs",
|
|
44
|
+
"wechat-opencode": "bin/wechat-opencode.mjs",
|
|
45
|
+
"wechat-opencode-start": "bin/wechat-opencode-start.mjs",
|
|
46
|
+
"wechat-daemon": "bin/wechat-daemon.mjs",
|
|
47
|
+
"wechat-setup": "bin/wechat-setup.mjs",
|
|
48
|
+
"wechat-check-update": "bin/wechat-check-update.mjs"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@anthropic-ai/sdk": "^0.80.0",
|
|
52
|
+
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
53
|
+
"@opencode-ai/sdk": "^1.3.2",
|
|
54
|
+
"ajv-formats": "^3.0.1",
|
|
55
|
+
"node-pty": "^1.1.0",
|
|
56
|
+
"qrcode-terminal": "^0.12.0"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"bun": ">=1.0.0",
|
|
60
|
+
"node": ">=24.0.0"
|
|
61
|
+
}
|
|
62
|
+
}
|