@agent-wechat/agent-wechat 0.12.1 → 0.13.0

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/dist/index.js CHANGED
@@ -1094,6 +1094,7 @@ function normalizeGroupPolicy(policy) {
1094
1094
  }
1095
1095
  var DEFAULT_POLL_INTERVAL_MS = 1e3;
1096
1096
  var DEFAULT_AUTH_POLL_INTERVAL_MS = 3e4;
1097
+ var DEFAULT_MEDIA_MAX_MB = 50;
1097
1098
  var DEFAULT_ACCOUNT_ID = "default";
1098
1099
  function resolveWeChatAccount(cfg, accountId) {
1099
1100
  const wechat = cfg.channels?.["agent-wechat"];
@@ -1109,7 +1110,8 @@ function resolveWeChatAccount(cfg, accountId) {
1109
1110
  groupAllowFrom: wechat.groupAllowFrom ?? [],
1110
1111
  groups: wechat.groups ?? {},
1111
1112
  pollIntervalMs: wechat.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
1112
- authPollIntervalMs: wechat.authPollIntervalMs ?? DEFAULT_AUTH_POLL_INTERVAL_MS
1113
+ authPollIntervalMs: wechat.authPollIntervalMs ?? DEFAULT_AUTH_POLL_INTERVAL_MS,
1114
+ mediaMaxMb: Number.isInteger(wechat.mediaMaxMb) && (wechat.mediaMaxMb ?? 0) > 0 && (wechat.mediaMaxMb ?? 0) <= 1024 ? wechat.mediaMaxMb : DEFAULT_MEDIA_MAX_MB
1113
1115
  };
1114
1116
  }
1115
1117
 
@@ -1154,16 +1156,16 @@ var WeChatClient = class {
1154
1156
  return this.base;
1155
1157
  }
1156
1158
  // ---- internal helpers ----
1157
- async get(path) {
1158
- const res = await fetch(`${this.base}${path}`, {
1159
+ async get(path2) {
1160
+ const res = await fetch(`${this.base}${path2}`, {
1159
1161
  headers: this.headers
1160
1162
  });
1161
1163
  if (!res.ok)
1162
1164
  throw new Error(`${res.status} ${res.statusText}: ${await res.text()}`);
1163
1165
  return res.json();
1164
1166
  }
1165
- async post(path, body) {
1166
- const res = await fetch(`${this.base}${path}`, {
1167
+ async post(path2, body) {
1168
+ const res = await fetch(`${this.base}${path2}`, {
1167
1169
  method: "POST",
1168
1170
  headers: this.headers,
1169
1171
  body: body != null ? JSON.stringify(body) : void 0
@@ -1172,8 +1174,8 @@ var WeChatClient = class {
1172
1174
  throw new Error(`${res.status} ${res.statusText}: ${await res.text()}`);
1173
1175
  return res.json();
1174
1176
  }
1175
- async del(path) {
1176
- const res = await fetch(`${this.base}${path}`, {
1177
+ async del(path2) {
1178
+ const res = await fetch(`${this.base}${path2}`, {
1177
1179
  method: "DELETE",
1178
1180
  headers: this.headers
1179
1181
  });
@@ -1222,8 +1224,8 @@ var WeChatClient = class {
1222
1224
  async listMessages(chatId, limit, offset) {
1223
1225
  return this.get(`/api/messages/${encodeURIComponent(chatId)}${qs({ limit, offset })}`);
1224
1226
  }
1225
- async getMedia(chatId, localId) {
1226
- return this.get(`/api/messages/${encodeURIComponent(chatId)}/media/${localId}`);
1227
+ async getMedia(chatId, localId, quality) {
1228
+ return this.get(`/api/messages/${encodeURIComponent(chatId)}/media/${localId}${qs({ quality })}`);
1227
1229
  }
1228
1230
  async sendMessage(params) {
1229
1231
  return this.post("/api/messages/send", params);
@@ -1775,8 +1777,8 @@ function getErrorMap() {
1775
1777
 
1776
1778
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js
1777
1779
  var makeIssue = (params) => {
1778
- const { data, path, errorMaps, issueData } = params;
1779
- const fullPath = [...path, ...issueData.path || []];
1780
+ const { data, path: path2, errorMaps, issueData } = params;
1781
+ const fullPath = [...path2, ...issueData.path || []];
1780
1782
  const fullIssue = {
1781
1783
  ...issueData,
1782
1784
  path: fullPath
@@ -1892,11 +1894,11 @@ var errorUtil;
1892
1894
 
1893
1895
  // ../../node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js
1894
1896
  var ParseInputLazyPath = class {
1895
- constructor(parent, value, path, key) {
1897
+ constructor(parent, value, path2, key) {
1896
1898
  this._cachedPath = [];
1897
1899
  this.parent = parent;
1898
1900
  this.data = value;
1899
- this._path = path;
1901
+ this._path = path2;
1900
1902
  this._key = key;
1901
1903
  }
1902
1904
  get path() {
@@ -5495,10 +5497,11 @@ var sendResultSchema = external_exports.object({
5495
5497
  });
5496
5498
  var getMediaParamsSchema = external_exports.object({
5497
5499
  chatId: external_exports.string().min(1),
5498
- localId: external_exports.number().int()
5500
+ localId: external_exports.number().int(),
5501
+ quality: external_exports.enum(["full", "thumbnail"]).optional()
5499
5502
  });
5500
5503
  var mediaResultSchema = external_exports.object({
5501
- type: external_exports.enum(["image", "emoji", "voice", "video", "unsupported"]),
5504
+ type: external_exports.enum(["image", "emoji", "voice", "video", "file", "pending", "unsupported"]),
5502
5505
  data: external_exports.string().optional(),
5503
5506
  url: external_exports.string().optional(),
5504
5507
  format: external_exports.string(),
@@ -5513,6 +5516,255 @@ var agentConfigSchema = external_exports.object({
5513
5516
  // src/monitor.ts
5514
5517
  import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
5515
5518
 
5519
+ // src/media-delivery.ts
5520
+ var MIME_BY_FORMAT = {
5521
+ jpeg: "image/jpeg",
5522
+ jpg: "image/jpeg",
5523
+ png: "image/png",
5524
+ gif: "image/gif",
5525
+ webp: "image/webp",
5526
+ mp3: "audio/mpeg",
5527
+ silk: "audio/silk",
5528
+ mp4: "video/mp4",
5529
+ pdf: "application/pdf",
5530
+ doc: "application/msword",
5531
+ docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
5532
+ xls: "application/vnd.ms-excel",
5533
+ xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5534
+ ppt: "application/vnd.ms-powerpoint",
5535
+ pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
5536
+ zip: "application/zip",
5537
+ txt: "text/plain"
5538
+ };
5539
+ function attachmentKindForMessageType(baseType) {
5540
+ if (baseType === 3) return "image";
5541
+ if (baseType === 34) return "audio";
5542
+ if (baseType === 43) return "video";
5543
+ if (baseType === 49) return "file";
5544
+ return void 0;
5545
+ }
5546
+ function attachmentKindForResult(result, fallback) {
5547
+ if (result.type === "image" || result.type === "emoji") return "image";
5548
+ if (result.type === "voice") return "audio";
5549
+ if (result.type === "video") return "video";
5550
+ if (result.type === "file") return "file";
5551
+ return fallback;
5552
+ }
5553
+ function mediaMime(format, kind) {
5554
+ const normalized = format.toLowerCase().replace(/^\./, "");
5555
+ return MIME_BY_FORMAT[normalized] ?? (kind === "image" ? "image/*" : kind === "audio" ? "audio/*" : kind === "video" ? "video/*" : "application/octet-stream");
5556
+ }
5557
+ function appendReference(body, reference) {
5558
+ return body ? `${body}
5559
+ ${reference}` : reference;
5560
+ }
5561
+ function safeDisplayName(name) {
5562
+ return name.replace(/[\r\n\0]/g, " ").trim() || "attachment";
5563
+ }
5564
+ function renderAttachmentBody(originalBody, attachment, exposeNonVisualPath = true) {
5565
+ if (!attachment) return originalBody;
5566
+ const name = safeDisplayName(attachment.filename);
5567
+ if (attachment.status !== "ready" || !attachment.path) {
5568
+ const label = attachment.kind === "file" ? "File" : attachment.kind === "image" ? "Image" : attachment.kind === "audio" ? "Voice message" : "Video";
5569
+ return appendReference(originalBody, `[${label} unavailable: ${name} (${attachment.status})]`);
5570
+ }
5571
+ if (attachment.kind === "file") {
5572
+ const prefix = originalBody.trim() === name ? "" : originalBody;
5573
+ return appendReference(prefix, `[File: ${name}]
5574
+ [Local file: ${attachment.path}]`);
5575
+ }
5576
+ if (exposeNonVisualPath) {
5577
+ if (attachment.kind === "audio") {
5578
+ return appendReference(originalBody || "<media:audio>", `[Voice message file: ${attachment.path}]`);
5579
+ }
5580
+ if (attachment.kind === "video") {
5581
+ return appendReference(originalBody || "<media:video>", `[Video file: ${attachment.path}]`);
5582
+ }
5583
+ }
5584
+ if (!originalBody) return attachment.kind === "image" ? "<media:image>" : originalBody;
5585
+ return originalBody;
5586
+ }
5587
+ function applyLiveAttachment(message, originalBody, attachment) {
5588
+ const attachable = attachment?.status === "ready" && attachment.path && attachment.kind !== "file";
5589
+ return {
5590
+ ...message,
5591
+ rawBody: renderAttachmentBody(originalBody, attachment),
5592
+ attachment,
5593
+ mediaPath: attachable ? attachment.path : void 0,
5594
+ mediaMime: attachable ? attachment.mime : void 0,
5595
+ hasMedia: attachment !== void 0
5596
+ };
5597
+ }
5598
+ function applyCatchupAttachmentPolicy(messages) {
5599
+ let latestImage = -1;
5600
+ for (let index = messages.length - 1; index >= 0; index--) {
5601
+ const attachment = messages[index].attachment;
5602
+ if (attachment?.kind === "image" && attachment.status === "ready" && attachment.path) {
5603
+ latestImage = index;
5604
+ break;
5605
+ }
5606
+ }
5607
+ return messages.map((message, index) => {
5608
+ const attachment = message.attachment;
5609
+ if (!attachment) return { ...message, mediaPath: void 0, mediaMime: void 0 };
5610
+ const selectImage = index === latestImage;
5611
+ let rawBody = renderAttachmentBody(message.rawBody, void 0);
5612
+ if (attachment.kind === "file") {
5613
+ rawBody = message.rawBody;
5614
+ } else if (!selectImage && attachment.status === "ready" && attachment.path) {
5615
+ const label = attachment.kind === "image" ? "Image file" : attachment.kind === "audio" ? "Voice message file" : "Video file";
5616
+ const reference = `[${label}: ${attachment.path}]`;
5617
+ rawBody = message.rawBody.includes(reference) ? message.rawBody : appendReference(message.rawBody, reference);
5618
+ }
5619
+ return {
5620
+ ...message,
5621
+ rawBody,
5622
+ mediaPath: selectImage ? attachment.path : void 0,
5623
+ mediaMime: selectImage ? attachment.mime : void 0,
5624
+ hasMedia: attachment !== void 0
5625
+ };
5626
+ });
5627
+ }
5628
+ function buildMediaSegments(messages) {
5629
+ const segments = [];
5630
+ let batch = [];
5631
+ let hasAttachedMedia = false;
5632
+ for (const message of messages) {
5633
+ if (message.mediaPath && hasAttachedMedia) {
5634
+ segments.push(batch);
5635
+ batch = [message];
5636
+ hasAttachedMedia = true;
5637
+ } else {
5638
+ batch.push(message);
5639
+ if (message.mediaPath) hasAttachedMedia = true;
5640
+ }
5641
+ }
5642
+ if (batch.length > 0) segments.push(batch);
5643
+ return segments;
5644
+ }
5645
+
5646
+ // src/attachment-store.ts
5647
+ import { createHash } from "node:crypto";
5648
+ import fs from "node:fs/promises";
5649
+ var AttachmentTooLargeError = class extends Error {
5650
+ };
5651
+ function decodedSize(base64) {
5652
+ if (!base64 || base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) {
5653
+ throw new Error("Invalid attachment encoding");
5654
+ }
5655
+ const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
5656
+ return base64.length / 4 * 3 - padding;
5657
+ }
5658
+ async function saveManagedAttachment(params) {
5659
+ const size = decodedSize(params.data);
5660
+ if (size > params.maxBytes) {
5661
+ throw new AttachmentTooLargeError(`Attachment exceeds configured ${params.maxBytes} byte limit`);
5662
+ }
5663
+ const buffer = Buffer.from(params.data, "base64");
5664
+ if (buffer.byteLength !== size) throw new Error("Attachment decoding length mismatch");
5665
+ const sha256 = createHash("sha256").update(buffer).digest("hex");
5666
+ const saved = await params.saveMediaBuffer(
5667
+ buffer,
5668
+ params.mime,
5669
+ "inbound",
5670
+ params.maxBytes,
5671
+ params.filename
5672
+ );
5673
+ return { path: saved.path, size, sha256, filename: params.filename };
5674
+ }
5675
+ async function attachmentPathExists(filePath) {
5676
+ if (!filePath) return false;
5677
+ try {
5678
+ return (await fs.stat(filePath)).isFile();
5679
+ } catch {
5680
+ return false;
5681
+ }
5682
+ }
5683
+
5684
+ // src/monitor-state.ts
5685
+ import { createHash as createHash2, randomUUID } from "node:crypto";
5686
+ import fs2 from "node:fs/promises";
5687
+ import path from "node:path";
5688
+ var emptyState = () => ({
5689
+ version: 1,
5690
+ lastSeen: /* @__PURE__ */ Object.create(null),
5691
+ groupHistory: /* @__PURE__ */ Object.create(null)
5692
+ });
5693
+ var MonitorStateStore = class {
5694
+ filePath;
5695
+ constructor(stateDir, accountId) {
5696
+ const account = createHash2("sha256").update(accountId).digest("hex").slice(0, 16);
5697
+ this.filePath = path.join(stateDir, "agent-wechat", "monitor", `${account}.json`);
5698
+ }
5699
+ async load(validateItem) {
5700
+ let parsed;
5701
+ try {
5702
+ parsed = JSON.parse(await fs2.readFile(this.filePath, "utf8"));
5703
+ } catch (error) {
5704
+ if (error.code === "ENOENT") return emptyState();
5705
+ throw error;
5706
+ }
5707
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1) {
5708
+ throw new Error("Unsupported WeChat monitor state");
5709
+ }
5710
+ const input = parsed;
5711
+ const state = emptyState();
5712
+ if (input.lastSeen && typeof input.lastSeen === "object") {
5713
+ for (const [chatId, localId] of Object.entries(input.lastSeen)) {
5714
+ if (typeof localId === "number" && Number.isSafeInteger(localId) && localId >= 0) {
5715
+ state.lastSeen[chatId] = localId;
5716
+ }
5717
+ }
5718
+ }
5719
+ if (input.groupHistory && typeof input.groupHistory === "object") {
5720
+ for (const [chatId, items] of Object.entries(input.groupHistory)) {
5721
+ if (Array.isArray(items) && items.every(validateItem)) state.groupHistory[chatId] = items;
5722
+ }
5723
+ }
5724
+ return state;
5725
+ }
5726
+ async save(lastSeen, groupHistory) {
5727
+ const directory = path.dirname(this.filePath);
5728
+ await fs2.mkdir(directory, { recursive: true, mode: 448 });
5729
+ const state = {
5730
+ version: 1,
5731
+ lastSeen: Object.fromEntries(lastSeen),
5732
+ groupHistory: Object.fromEntries(groupHistory)
5733
+ };
5734
+ const temporary = path.join(directory, `.${path.basename(this.filePath)}.${randomUUID()}.tmp`);
5735
+ try {
5736
+ await fs2.writeFile(temporary, JSON.stringify(state), { flag: "wx", mode: 384 });
5737
+ await fs2.rename(temporary, this.filePath);
5738
+ } finally {
5739
+ await fs2.rm(temporary, { force: true }).catch(() => void 0);
5740
+ }
5741
+ }
5742
+ };
5743
+
5744
+ // src/polling.ts
5745
+ async function listAllChats(client) {
5746
+ const pageSize = 100;
5747
+ const chats = [];
5748
+ for (let offset = 0; ; offset += pageSize) {
5749
+ const page = await client.listChats(pageSize, offset);
5750
+ chats.push(...page);
5751
+ if (page.length < pageSize) return chats;
5752
+ }
5753
+ }
5754
+ async function listMessageWindow(client, chatId, previousLastSeen, firstPoll, unreadCount) {
5755
+ const pageSize = 200;
5756
+ const messages = [];
5757
+ const firstPollTarget = Math.max(unreadCount, 20);
5758
+ for (let offset = 0; ; offset += pageSize) {
5759
+ const page = await client.listMessages(chatId, pageSize, offset);
5760
+ messages.push(...page);
5761
+ if (page.length < pageSize) break;
5762
+ if (firstPoll && messages.length >= firstPollTarget) break;
5763
+ if (!firstPoll && page.some((message) => message.localId <= previousLastSeen)) break;
5764
+ }
5765
+ return messages;
5766
+ }
5767
+
5516
5768
  // src/access-control.ts
5517
5769
  import {
5518
5770
  buildChannelKeyCandidates,
@@ -5721,9 +5973,13 @@ function resolveWeChatMentionGate(params) {
5721
5973
  }
5722
5974
 
5723
5975
  // src/monitor.ts
5724
- var MEDIA_TYPES = /* @__PURE__ */ new Set([3, 34, 43]);
5725
5976
  var HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
5726
5977
  var CURRENT_MESSAGE_MARKER = "[Current message - respond to this]";
5978
+ function isProcessedMessage(value) {
5979
+ if (!value || typeof value !== "object") return false;
5980
+ const message = value;
5981
+ return !!message.msg && typeof message.rawBody === "string" && typeof message.commandBody === "string" && typeof message.senderName === "string" && typeof message.senderId === "string" && typeof message.isGroup === "boolean" && typeof message.timestamp === "number" && typeof message.hasMedia === "boolean" && typeof message.isMentioned === "boolean" && typeof message.sourceBody === "string";
5982
+ }
5727
5983
  function isOfficialAccount(chatId) {
5728
5984
  return chatId.startsWith("gh_");
5729
5985
  }
@@ -5737,10 +5993,12 @@ function sleep(ms, signal) {
5737
5993
  });
5738
5994
  }
5739
5995
  async function pollMedia(client, chatId, localId, log, maxAttempts = 15, intervalMs = 1e3) {
5996
+ let lastResult = null;
5740
5997
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
5741
- const result = await client.getMedia(chatId, localId);
5998
+ const result = await client.getMedia(chatId, localId, "full");
5999
+ lastResult = result;
5742
6000
  if (result.type === "unsupported") {
5743
- return null;
6001
+ return result;
5744
6002
  }
5745
6003
  if (result.data) {
5746
6004
  return result;
@@ -5750,7 +6008,7 @@ async function pollMedia(client, chatId, localId, log, maxAttempts = 15, interva
5750
6008
  await new Promise((r) => setTimeout(r, intervalMs));
5751
6009
  }
5752
6010
  }
5753
- return null;
6011
+ return lastResult;
5754
6012
  }
5755
6013
  function enqueueWeChatSystemEvent(text, contextKey) {
5756
6014
  try {
@@ -5762,12 +6020,71 @@ function enqueueWeChatSystemEvent(text, contextKey) {
5762
6020
  } catch {
5763
6021
  }
5764
6022
  }
6023
+ async function retrieveAttachment(client, chatId, msg, liveAccount, log, maxAttempts = 15) {
6024
+ const baseType = msg.type & 2147483647;
6025
+ const expectedKind = attachmentKindForMessageType(baseType);
6026
+ if (!expectedKind) return void 0;
6027
+ try {
6028
+ const result = await pollMedia(client, chatId, msg.localId, log, maxAttempts);
6029
+ if (!result) {
6030
+ return { kind: expectedKind, status: "unavailable", filename: msg.content || `message-${msg.localId}` };
6031
+ }
6032
+ if (result.type === "unsupported") {
6033
+ if (baseType === 49) return void 0;
6034
+ return { kind: expectedKind, status: "unsupported", filename: msg.content || `message-${msg.localId}` };
6035
+ }
6036
+ const kind = attachmentKindForResult(result, expectedKind);
6037
+ const filename = result.filename || msg.content || `message-${msg.localId}`;
6038
+ if (!result.data) {
6039
+ return { kind, status: result.type === "pending" ? "pending" : "unavailable", filename };
6040
+ }
6041
+ const mime = mediaMime(result.format, kind);
6042
+ const saved = await saveManagedAttachment({
6043
+ data: result.data,
6044
+ mime,
6045
+ filename,
6046
+ maxBytes: liveAccount.mediaMaxMb * 1024 * 1024,
6047
+ saveMediaBuffer: getWeChatRuntime().channel.media.saveMediaBuffer
6048
+ });
6049
+ log?.info?.(
6050
+ `[wechat:${liveAccount.accountId}] Saved ${kind} attachment for msg ${msg.localId} (${saved.size} bytes)`
6051
+ );
6052
+ return { kind, status: "ready", filename: saved.filename, mime, path: saved.path };
6053
+ } catch (error) {
6054
+ const status = error instanceof AttachmentTooLargeError ? "too_large" : "error";
6055
+ log?.error?.(
6056
+ `[wechat:${liveAccount.accountId}] Attachment retrieval failed for msg ${msg.localId} (${status})`
6057
+ );
6058
+ return {
6059
+ kind: expectedKind,
6060
+ status,
6061
+ filename: msg.content || `message-${msg.localId}`,
6062
+ error: error instanceof Error ? error.message : String(error)
6063
+ };
6064
+ }
6065
+ }
5765
6066
  async function startWeChatMonitor(opts) {
5766
6067
  const { account, abortSignal, setStatus, log } = opts;
5767
6068
  const client = new WeChatClient({ baseUrl: account.serverUrl, token: account.token });
5768
- const lastSeenId = /* @__PURE__ */ new Map();
5769
- const groupHistory = /* @__PURE__ */ new Map();
6069
+ const runtimeStateDir = getWeChatRuntime().state.resolveStateDir(process.env);
6070
+ const stateStore = new MonitorStateStore(runtimeStateDir, account.accountId);
6071
+ let savedState;
6072
+ try {
6073
+ savedState = await stateStore.load(isProcessedMessage);
6074
+ } catch (error) {
6075
+ log?.error?.(`[wechat:${account.accountId}] Ignoring unreadable monitor state: ${String(error)}`);
6076
+ savedState = { version: 1, lastSeen: {}, groupHistory: {} };
6077
+ }
6078
+ const lastSeenId = new Map(Object.entries(savedState.lastSeen));
6079
+ const groupHistory = new Map(Object.entries(savedState.groupHistory));
5770
6080
  const GROUP_HISTORY_LIMIT = 50;
6081
+ const persistState = async () => {
6082
+ try {
6083
+ await stateStore.save(lastSeenId, groupHistory);
6084
+ } catch (error) {
6085
+ log?.error?.(`[wechat:${account.accountId}] Failed to persist monitor state: ${String(error)}`);
6086
+ }
6087
+ };
5771
6088
  let lastAuthCheck = 0;
5772
6089
  let prevStatus = void 0;
5773
6090
  setStatus({
@@ -5831,7 +6148,7 @@ async function startWeChatMonitor(opts) {
5831
6148
  }
5832
6149
  let chats;
5833
6150
  try {
5834
- chats = await client.listChats(50);
6151
+ chats = await listAllChats(client);
5835
6152
  } catch (err) {
5836
6153
  log?.error?.(
5837
6154
  `[wechat:${account.accountId}] Failed to list chats: ${err}`
@@ -5859,7 +6176,8 @@ async function startWeChatMonitor(opts) {
5859
6176
  log,
5860
6177
  void 0,
5861
6178
  groupHistory,
5862
- GROUP_HISTORY_LIMIT
6179
+ GROUP_HISTORY_LIMIT,
6180
+ persistState
5863
6181
  );
5864
6182
  }
5865
6183
  }
@@ -5874,7 +6192,7 @@ async function startWeChatMonitor(opts) {
5874
6192
  log?.info?.(
5875
6193
  `[wechat:${account.accountId}] Catch-up: ${chatId} lastMsgLocalId=${chat.lastMsgLocalId} > lastSeenId=${prevSeen}`
5876
6194
  );
5877
- await processUnreadChat(client, chat, lastSeenId, account, cfg, log, true, groupHistory, GROUP_HISTORY_LIMIT);
6195
+ await processUnreadChat(client, chat, lastSeenId, account, cfg, log, true, groupHistory, GROUP_HISTORY_LIMIT, persistState);
5878
6196
  }
5879
6197
  } catch (err) {
5880
6198
  log?.error?.(
@@ -5890,7 +6208,6 @@ async function startWeChatMonitor(opts) {
5890
6208
  });
5891
6209
  }
5892
6210
  async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, log) {
5893
- const core = getWeChatRuntime();
5894
6211
  if (msg.isSelf) {
5895
6212
  log?.info?.(`[wechat:${liveAccount.accountId}] Skipping self-sent msg ${msg.localId}`);
5896
6213
  return null;
@@ -5910,113 +6227,67 @@ async function prepareMessage(client, msg, chatId, chat, liveAccount, policy, lo
5910
6227
  );
5911
6228
  return null;
5912
6229
  }
5913
- let mediaPath;
5914
- let mediaMime;
5915
- let hasMedia = false;
5916
6230
  const baseType = msg.type & 2147483647;
5917
- const mayHaveMedia = MEDIA_TYPES.has(baseType) || baseType === 49;
5918
- if (mayHaveMedia) {
6231
+ let attachment;
6232
+ if (attachmentKindForMessageType(baseType)) {
5919
6233
  log?.info?.(`[wechat:${liveAccount.accountId}] Checking media for msg ${msg.localId} (type ${baseType})`);
5920
- try {
5921
- const result = await pollMedia(client, chatId, msg.localId, log);
5922
- if (result && result.data && result.type !== "unsupported") {
5923
- hasMedia = true;
5924
- log?.info?.(`[wechat:${liveAccount.accountId}] Media result: type=${result.type}, format=${result.format}, hasData=${!!result.data}, filename=${result.filename}`);
5925
- const mimeMap = {
5926
- jpeg: "image/jpeg",
5927
- jpg: "image/jpeg",
5928
- png: "image/png",
5929
- gif: "image/gif",
5930
- mp3: "audio/mpeg",
5931
- pdf: "application/pdf",
5932
- doc: "application/msword",
5933
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
5934
- xls: "application/vnd.ms-excel",
5935
- xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
5936
- ppt: "application/vnd.ms-powerpoint",
5937
- pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
5938
- zip: "application/zip",
5939
- txt: "text/plain"
5940
- };
5941
- mediaMime = mimeMap[result.format] ?? `application/${result.format || "octet-stream"}`;
5942
- const buf = Buffer.from(result.data, "base64");
5943
- const saved = await core.channel.media.saveMediaBuffer(
5944
- buf,
5945
- mediaMime,
5946
- "inbound",
5947
- void 0,
5948
- result.filename
5949
- );
5950
- mediaPath = saved?.path;
5951
- log?.info?.(`[wechat:${liveAccount.accountId}] Saved media to ${mediaPath}`);
5952
- } else if (MEDIA_TYPES.has(baseType)) {
5953
- hasMedia = true;
5954
- log?.info?.(`[wechat:${liveAccount.accountId}] Media not available after retries for msg ${msg.localId}`);
5955
- }
5956
- } catch (err) {
5957
- log?.error?.(`[wechat:${liveAccount.accountId}] Media download failed: ${err}`);
5958
- }
6234
+ attachment = await retrieveAttachment(client, chatId, msg, liveAccount, log);
5959
6235
  }
5960
6236
  const timestamp = new Date(msg.timestamp).getTime();
5961
- let rawBody = msg.content || "";
5962
- if (mediaPath && mediaMime) {
5963
- if (!rawBody) {
5964
- if (mediaMime.startsWith("audio/")) {
5965
- rawBody = "<media:audio>";
5966
- } else if (mediaMime.startsWith("image/")) {
5967
- rawBody = "<media:image>";
5968
- } else {
5969
- rawBody = "<media:file>";
5970
- }
5971
- } else if (!mediaMime.startsWith("image/") && !mediaMime.startsWith("audio/")) {
5972
- rawBody = `[File: ${rawBody}]`;
5973
- }
5974
- }
6237
+ let sourceBody = msg.content || "";
5975
6238
  if (msg.reply) {
5976
6239
  const replySender = msg.reply.sender ?? "unknown sender";
5977
6240
  const quotedBody = msg.reply.content.length > 50 ? msg.reply.content.slice(0, 50) + "..." : msg.reply.content;
5978
6241
  const replyBlock = `[Replying to ${replySender}]
5979
6242
  ${quotedBody}
5980
6243
  [/Replying]`;
5981
- rawBody = rawBody ? `${rawBody}
6244
+ sourceBody = sourceBody ? `${sourceBody}
5982
6245
 
5983
6246
  ${replyBlock}` : replyBlock;
5984
6247
  }
5985
- return {
6248
+ const prepared = applyLiveAttachment({
5986
6249
  msg,
5987
- rawBody,
5988
- commandBody: normalizeWeChatCommandBody(rawBody, {
5989
- isGroup,
5990
- wasMentioned
5991
- }),
5992
- mediaPath,
5993
- mediaMime,
6250
+ rawBody: sourceBody,
6251
+ sourceBody,
6252
+ commandBody: "",
5994
6253
  senderName,
5995
6254
  senderId,
5996
6255
  isGroup,
5997
6256
  timestamp,
5998
- hasMedia,
6257
+ hasMedia: false,
5999
6258
  isMentioned: wasMentioned
6000
- };
6259
+ }, sourceBody, attachment);
6260
+ prepared.commandBody = normalizeWeChatCommandBody(prepared.rawBody, {
6261
+ isGroup,
6262
+ wasMentioned
6263
+ });
6264
+ return prepared;
6001
6265
  }
6002
- function buildSegments(processed) {
6003
- const segments = [];
6004
- let currentBatch = [];
6005
- let mediaCount = 0;
6006
- for (const pm of processed) {
6007
- if (pm.hasMedia && mediaCount >= 1) {
6008
- segments.push(currentBatch);
6009
- currentBatch = [pm];
6010
- mediaCount = 1;
6011
- } else {
6012
- if (pm.hasMedia) mediaCount++;
6013
- currentBatch.push(pm);
6266
+ async function refreshAttachment(message, client, chatId, liveAccount, log) {
6267
+ if (!message.attachment) return message;
6268
+ if (message.attachment.status === "ready" && await attachmentPathExists(message.attachment.path)) {
6269
+ return message;
6270
+ }
6271
+ log?.info?.(`[wechat:${liveAccount.accountId}] Retrying attachment for msg ${message.msg.localId}`);
6272
+ const attachment = await retrieveAttachment(client, chatId, message.msg, liveAccount, log, 3);
6273
+ const refreshed = applyLiveAttachment(message, message.sourceBody, attachment);
6274
+ refreshed.commandBody = normalizeWeChatCommandBody(refreshed.rawBody, {
6275
+ isGroup: refreshed.isGroup,
6276
+ wasMentioned: refreshed.isMentioned
6277
+ });
6278
+ return refreshed;
6279
+ }
6280
+ async function refreshAttachments(messages, client, chatId, liveAccount, log) {
6281
+ const output = [...messages];
6282
+ let cursor = 0;
6283
+ const worker = async () => {
6284
+ while (cursor < messages.length) {
6285
+ const index = cursor++;
6286
+ output[index] = await refreshAttachment(messages[index], client, chatId, liveAccount, log);
6014
6287
  }
6015
- }
6016
- if (currentBatch.length > 0) {
6017
- segments.push(currentBatch);
6018
- }
6019
- return segments;
6288
+ };
6289
+ await Promise.all(Array.from({ length: Math.min(3, messages.length) }, worker));
6290
+ return output;
6020
6291
  }
6021
6292
  async function dispatchSegment(segment, client, chatId, chat, liveAccount, policy, storeAllowFrom, allowTextCommands, cfg, log, remainingSegments) {
6022
6293
  const core = getWeChatRuntime();
@@ -6024,9 +6295,18 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6024
6295
  const { isGroup, senderId, senderName, timestamp, rawBody, commandBody, msg } = lastMsg;
6025
6296
  const mediaMsg = segment.find((pm) => pm.mediaPath);
6026
6297
  const mediaPath = mediaMsg?.mediaPath;
6027
- const mediaMime = mediaMsg?.mediaMime;
6298
+ const mediaMime2 = mediaMsg?.mediaMime;
6299
+ const attachmentPaths = segment.flatMap(
6300
+ (pm) => pm.attachment?.status === "ready" && pm.attachment.path ? [{
6301
+ path: pm.attachment.path,
6302
+ filename: pm.attachment.filename,
6303
+ kind: pm.attachment.kind,
6304
+ messageId: pm.msg.localId,
6305
+ sender: pm.senderName
6306
+ }] : []
6307
+ );
6028
6308
  log?.info?.(
6029
- `[wechat:${liveAccount.accountId}] Dispatching segment: ${segment.length} msg(s), last=${msg.localId}${mediaPath ? ` media=${mediaPath}` : ""}`
6309
+ `[wechat:${liveAccount.accountId}] Dispatching segment: ${segment.length} msg(s), last=${msg.localId}${mediaPath ? " with model media" : ""} attachments=${attachmentPaths.length}`
6030
6310
  );
6031
6311
  const hasControlCommand = allowTextCommands && core.channel.commands.isControlCommandMessage(commandBody, cfg);
6032
6312
  const commandAuthorized = await resolveWeChatCommandAuthorization({
@@ -6046,7 +6326,7 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6046
6326
  log?.info?.(
6047
6327
  `[wechat:${liveAccount.accountId}] Dropping unauthorized group control command from ${senderId} in ${chatId}`
6048
6328
  );
6049
- return false;
6329
+ return "ignored";
6050
6330
  }
6051
6331
  const mentionGate = resolveWeChatMentionGate({
6052
6332
  isGroup,
@@ -6061,7 +6341,7 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6061
6341
  log?.info?.(
6062
6342
  `[wechat:${liveAccount.accountId}] Skipping group segment (mention required) in ${chatId}`
6063
6343
  );
6064
- return false;
6344
+ return "ignored";
6065
6345
  }
6066
6346
  try {
6067
6347
  const route = core.channel.routing.resolveAgentRoute({
@@ -6153,7 +6433,8 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6153
6433
  CommandAuthorized: commandAuthorized,
6154
6434
  OriginatingChannel: "agent-wechat",
6155
6435
  OriginatingTo: `wechat:${chatId}`,
6156
- ...mediaPath ? { MediaPath: mediaPath, MediaUrl: mediaPath, MediaType: mediaMime } : {},
6436
+ Attachments: attachmentPaths,
6437
+ ...mediaPath ? { MediaPath: mediaPath, MediaUrl: mediaPath, MediaType: mediaMime2 } : {},
6157
6438
  ...msg.reply ? {
6158
6439
  ReplyToBody: msg.reply.content.length > 50 ? msg.reply.content.slice(0, 50) + "..." : msg.reply.content,
6159
6440
  ReplyToSender: msg.reply.sender
@@ -6175,6 +6456,7 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6175
6456
  channel: "agent-wechat",
6176
6457
  accountId: liveAccount.accountId
6177
6458
  });
6459
+ let replyFailed = false;
6178
6460
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
6179
6461
  ctx: ctxPayload,
6180
6462
  cfg,
@@ -6238,6 +6520,7 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6238
6520
  }
6239
6521
  },
6240
6522
  onError: (err, info) => {
6523
+ replyFailed = true;
6241
6524
  log?.error?.(
6242
6525
  `[wechat:${liveAccount.accountId}] ${info.kind} reply failed: ${String(err)}`
6243
6526
  );
@@ -6247,18 +6530,19 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
6247
6530
  onModelSelected
6248
6531
  }
6249
6532
  });
6533
+ if (replyFailed) return "failed";
6250
6534
  core.channel.activity?.record?.({
6251
6535
  channel: "agent-wechat",
6252
6536
  accountId: liveAccount.accountId,
6253
6537
  direction: "inbound",
6254
6538
  at: timestamp
6255
6539
  });
6256
- return true;
6540
+ return "delivered";
6257
6541
  } catch (err) {
6258
6542
  log?.error?.(
6259
6543
  `[wechat:${liveAccount.accountId}] Failed to dispatch segment (last msg ${msg.localId}): ${err}`
6260
6544
  );
6261
- return false;
6545
+ return "failed";
6262
6546
  }
6263
6547
  }
6264
6548
  function bufferGroupHistory(groupHistory, chatId, pm, limit) {
@@ -6274,7 +6558,7 @@ function bufferGroupHistory(groupHistory, chatId, pm, limit) {
6274
6558
  if (first) groupHistory.delete(first);
6275
6559
  }
6276
6560
  }
6277
- async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, skipOpen, groupHistory, groupHistoryLimit) {
6561
+ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, skipOpen, groupHistory, groupHistoryLimit, persistState) {
6278
6562
  const core = getWeChatRuntime();
6279
6563
  const liveAccount = resolveWeChatAccount(cfg, account.accountId) ?? account;
6280
6564
  const chatId = chat.username ?? chat.id;
@@ -6292,8 +6576,12 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6292
6576
  if (!skipOpen) {
6293
6577
  log?.info?.(`[wechat:${liveAccount.accountId}] Opening chat ${chatId}...`);
6294
6578
  try {
6295
- await client.openChat(chatId, true);
6296
- log?.info?.(`[wechat:${liveAccount.accountId}] Opened chat ${chatId}`);
6579
+ const opened = await client.openChat(chatId, true);
6580
+ if (opened.ok) {
6581
+ log?.info?.(`[wechat:${liveAccount.accountId}] Opened chat ${chatId}`);
6582
+ } else {
6583
+ log?.error?.(`[wechat:${liveAccount.accountId}] Chat selection did not complete for ${chatId}: ${opened.error ?? "unknown error"}`);
6584
+ }
6297
6585
  } catch (err) {
6298
6586
  log?.error?.(
6299
6587
  `[wechat:${liveAccount.accountId}] Failed to open chat ${chatId}: ${err}`
@@ -6302,10 +6590,9 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6302
6590
  }
6303
6591
  const firstPoll = !lastSeenId.has(chatId);
6304
6592
  const prevLastSeen = lastSeenId.get(chatId) ?? 0;
6305
- const fetchLimit = Math.max(chat.unreadCount, 20);
6306
6593
  let messages;
6307
6594
  try {
6308
- messages = await client.listMessages(chatId, fetchLimit);
6595
+ messages = await listMessageWindow(client, chatId, prevLastSeen, firstPoll, chat.unreadCount ?? 0);
6309
6596
  } catch (err) {
6310
6597
  log?.error?.(
6311
6598
  `[wechat:${liveAccount.accountId}] Failed to list messages for ${chatId}: ${err}`
@@ -6324,11 +6611,13 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6324
6611
  newMessages = messages.slice(-unread);
6325
6612
  const seenMax = messages[messages.length - unread - 1].localId;
6326
6613
  lastSeenId.set(chatId, seenMax);
6614
+ await persistState?.();
6327
6615
  } else if (unread >= messages.length) {
6328
6616
  newMessages = messages;
6329
6617
  } else {
6330
6618
  const maxId2 = messages[messages.length - 1].localId;
6331
6619
  lastSeenId.set(chatId, maxId2);
6620
+ await persistState?.();
6332
6621
  return;
6333
6622
  }
6334
6623
  } else {
@@ -6344,7 +6633,7 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6344
6633
  const processed = [];
6345
6634
  for (const msg of newMessages) {
6346
6635
  log?.info?.(
6347
- `[wechat:${liveAccount.accountId}] Processing msg ${msg.localId}: type=${msg.type}, sender=${msg.sender}, isSelf=${msg.isSelf}, content=${(msg.content || "").slice(0, 50)}`
6636
+ `[wechat:${liveAccount.accountId}] Processing msg ${msg.localId}: type=${msg.type}, sender=${msg.sender}, isSelf=${msg.isSelf}`
6348
6637
  );
6349
6638
  const pm = await prepareMessage(client, msg, chatId, chat, liveAccount, policy, log);
6350
6639
  if (pm) {
@@ -6365,51 +6654,35 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6365
6654
  log?.info?.(`[wechat:${liveAccount.accountId}] Buffered ${processed.length} msg(s) for group history in ${chatId}`);
6366
6655
  const maxId2 = Math.max(...newMessages.map((m) => m.localId));
6367
6656
  lastSeenId.set(chatId, maxId2);
6657
+ await persistState?.();
6368
6658
  return;
6369
6659
  }
6370
6660
  if (hasMention) {
6371
6661
  clearBufferedHistory = true;
6372
6662
  const buffered = groupHistory.get(chatId) ?? [];
6373
6663
  if (buffered.length > 0) {
6374
- for (const pm of buffered) {
6375
- pm.isMentioned = true;
6376
- }
6377
- processed.unshift(...buffered);
6664
+ processed.unshift(...buffered.map((pm) => ({ ...pm, isMentioned: true })));
6378
6665
  log?.info?.(
6379
6666
  `[wechat:${liveAccount.accountId}] Injected ${buffered.length} buffered msg(s) as history in ${chatId}`
6380
6667
  );
6381
6668
  }
6382
- let latestMediaIdx = -1;
6383
- for (let i = processed.length - 1; i >= 0; i--) {
6384
- if (processed[i].mediaPath) {
6385
- latestMediaIdx = i;
6386
- break;
6387
- }
6388
- }
6389
- for (let i = 0; i < processed.length; i++) {
6390
- if (processed[i].mediaPath && i !== latestMediaIdx) {
6391
- processed[i] = {
6392
- ...processed[i],
6393
- mediaPath: void 0,
6394
- mediaMime: void 0,
6395
- hasMedia: false
6396
- };
6397
- }
6398
- }
6669
+ processed.splice(0, processed.length, ...await refreshAttachments(processed, client, chatId, liveAccount, log));
6670
+ processed.splice(0, processed.length, ...applyCatchupAttachmentPolicy(processed));
6399
6671
  }
6400
6672
  } else {
6401
6673
  clearBufferedHistory = true;
6402
6674
  }
6403
6675
  }
6404
6676
  if (processed.length > 0) {
6405
- const segments = hasControlCommandInWindow ? processed.map((pm) => [pm]) : buildSegments(processed);
6677
+ const segments = hasControlCommandInWindow ? processed.map((pm) => [pm]) : buildMediaSegments(processed);
6406
6678
  log?.info?.(
6407
6679
  `[wechat:${liveAccount.accountId}] ${chatId}: ${processed.length} dispatchable msg(s) in ${segments.length} segment(s)`
6408
6680
  );
6409
6681
  let allDispatched = true;
6682
+ let allHandled = true;
6410
6683
  for (let i = 0; i < segments.length; i++) {
6411
6684
  const remaining = segments.length - i - 1;
6412
- const dispatched = await dispatchSegment(
6685
+ const outcome = await dispatchSegment(
6413
6686
  segments[i],
6414
6687
  client,
6415
6688
  chatId,
@@ -6422,16 +6695,22 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6422
6695
  log,
6423
6696
  hasControlCommandInWindow ? void 0 : remaining
6424
6697
  );
6425
- if (!dispatched) {
6698
+ if (outcome !== "delivered") {
6426
6699
  allDispatched = false;
6427
6700
  }
6701
+ if (outcome === "failed") allHandled = false;
6428
6702
  }
6429
6703
  if (clearBufferedHistory && allDispatched && groupHistory) {
6430
6704
  groupHistory.set(chatId, []);
6431
6705
  }
6706
+ if (!allHandled) {
6707
+ await persistState?.();
6708
+ return;
6709
+ }
6432
6710
  }
6433
6711
  const maxId = Math.max(...newMessages.map((m) => m.localId));
6434
6712
  lastSeenId.set(chatId, maxId);
6713
+ await persistState?.();
6435
6714
  }
6436
6715
 
6437
6716
  // src/status.ts
@@ -6926,7 +7205,8 @@ var wechatPlugin = {
6926
7205
  }
6927
7206
  },
6928
7207
  pollIntervalMs: { type: "integer", minimum: 100 },
6929
- authPollIntervalMs: { type: "integer", minimum: 1e3 }
7208
+ authPollIntervalMs: { type: "integer", minimum: 1e3 },
7209
+ mediaMaxMb: { type: "integer", minimum: 1, maximum: 1024 }
6930
7210
  }
6931
7211
  }
6932
7212
  },
@@ -6950,7 +7230,8 @@ var wechatPlugin = {
6950
7230
  groupAllowFrom: [],
6951
7231
  groups: {},
6952
7232
  pollIntervalMs: 1e3,
6953
- authPollIntervalMs: 3e4
7233
+ authPollIntervalMs: 3e4,
7234
+ mediaMaxMb: 50
6954
7235
  };
6955
7236
  }
6956
7237
  return account;
@@ -91,6 +91,11 @@
91
91
  "authPollIntervalMs": {
92
92
  "type": "integer",
93
93
  "minimum": 1000
94
+ },
95
+ "mediaMaxMb": {
96
+ "type": "integer",
97
+ "minimum": 1,
98
+ "maximum": 1024
94
99
  }
95
100
  }
96
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-wechat/agent-wechat",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -11,7 +11,7 @@
11
11
  "esbuild": "^0.25.0",
12
12
  "openclaw": "^2026.8.1",
13
13
  "typescript": "^5.4.5",
14
- "@agent-wechat/shared": "0.1.0"
14
+ "@agent-wechat/shared": "0.2.0"
15
15
  },
16
16
  "openclaw": {
17
17
  "extensions": [
@@ -106,6 +106,11 @@
106
106
  "authPollIntervalMs": {
107
107
  "type": "integer",
108
108
  "minimum": 1000
109
+ },
110
+ "mediaMaxMb": {
111
+ "type": "integer",
112
+ "minimum": 1,
113
+ "maximum": 1024
109
114
  }
110
115
  }
111
116
  }