@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.
@@ -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 extractTextFromMessage(message) {
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 lines.join("\n").trim();
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 = new Map(Object.entries(readJsonFile(CONTEXT_CACHE_FILE) ?? {}));
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 text = extractTextFromMessage(rawMessage);
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",
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
- "typescript": "^6.0.2"
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",