@kyan-du/agent-wechat-openclaw 0.14.3 → 0.14.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.
Files changed (2) hide show
  1. package/dist/index.js +377 -38
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -5304,6 +5304,15 @@ var downloadFileParamsSchema = external_exports.object({
5304
5304
  var downloadFileResultSchema = openChatResultSchema.extend({
5305
5305
  clicked: external_exports.boolean().optional()
5306
5306
  });
5307
+ var materializeChatHistoryParamsSchema = external_exports.object({
5308
+ chatId: external_exports.string().min(1),
5309
+ title: external_exports.string().min(1).optional(),
5310
+ localId: external_exports.number().int().optional(),
5311
+ executionTimeoutMs: external_exports.number().int().positive().optional()
5312
+ });
5313
+ var materializeChatHistoryResultSchema = openChatResultSchema.extend({
5314
+ clicked: external_exports.boolean().optional()
5315
+ });
5307
5316
  var idempotencyKeySchema = external_exports.string().min(1).max(128).regex(/^[A-Za-z0-9._:-]+$/);
5308
5317
  var forwardedMessageNodeSchema = external_exports.lazy(() => external_exports.object({
5309
5318
  sender: external_exports.string().optional(),
@@ -5417,7 +5426,8 @@ var mediaResultSchema = external_exports.object({
5417
5426
  format: external_exports.string(),
5418
5427
  filename: external_exports.string(),
5419
5428
  source: external_exports.enum(["original", "thumbnail"]).optional(),
5420
- errorCode: external_exports.string().optional()
5429
+ errorCode: external_exports.string().optional(),
5430
+ items: external_exports.array(external_exports.lazy(() => mediaResultSchema)).optional()
5421
5431
  });
5422
5432
  var agentConfigSchema = external_exports.object({
5423
5433
  maxTurns: external_exports.number().int().positive().default(30),
@@ -5501,6 +5511,17 @@ var WeChatClient = class {
5501
5511
  await throwHttpError(res);
5502
5512
  return res.json();
5503
5513
  }
5514
+ async put(path, body, signal) {
5515
+ const res = await fetch(`${this.base}${path}`, {
5516
+ method: "PUT",
5517
+ headers: this.headers,
5518
+ body: body != null ? JSON.stringify(body) : void 0,
5519
+ signal
5520
+ });
5521
+ if (!res.ok)
5522
+ await throwHttpError(res);
5523
+ return res.json();
5524
+ }
5504
5525
  // ---- Status ----
5505
5526
  async status() {
5506
5527
  return this.get("/api/status");
@@ -5522,12 +5543,18 @@ var WeChatClient = class {
5522
5543
  return this.post("/api/status/auth/reset");
5523
5544
  }
5524
5545
  // ---- Chats ----
5525
- async listChatsPage(limit, cursor, unreadOnly) {
5526
- const page = await this.get(`/api/chats${qs({ limit, cursor, unreadOnly })}`);
5546
+ async listChatsPage(limit, cursor, unreadOnly, interestOnly) {
5547
+ const page = await this.get(`/api/chats${qs({ limit, cursor, unreadOnly, interestOnly })}`);
5527
5548
  if (page.errorCode)
5528
5549
  throw new WeChatHttpError(400, "Bad Request", page.errorCode, page.errorCode);
5529
5550
  return page;
5530
5551
  }
5552
+ async getMonitorInterest() {
5553
+ return this.get("/api/interest");
5554
+ }
5555
+ async setMonitorInterest(interest) {
5556
+ return this.put("/api/interest", interest);
5557
+ }
5531
5558
  async listChats(limit) {
5532
5559
  return (await this.listChatsPage(limit)).items;
5533
5560
  }
@@ -5546,6 +5573,10 @@ var WeChatClient = class {
5546
5573
  async downloadFile(chatId, filename, signal, executionTimeoutMs) {
5547
5574
  return this.post(`/api/chats/${encodeURIComponent(chatId)}/download-file${qs({ filename, executionTimeoutMs })}`, void 0, signal);
5548
5575
  }
5576
+ async materializeChatHistory(chatId, options, signal) {
5577
+ const { title, localId, executionTimeoutMs } = options ?? {};
5578
+ return this.post(`/api/chats/${encodeURIComponent(chatId)}/materialize-chat-history${qs({ title, localId, executionTimeoutMs })}`, void 0, signal);
5579
+ }
5549
5580
  async listGroupMembersPage(groupId, limit, cursor) {
5550
5581
  const page = groupMembersPageSchema.parse(await this.get(`/api/groups/${encodeURIComponent(groupId)}/members${qs({ limit, cursor })}`));
5551
5582
  if (page.errorCode)
@@ -5671,6 +5702,65 @@ var WeChatClient = class {
5671
5702
  // src/monitor.ts
5672
5703
  import { createChannelMessageReplyPipeline as createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
5673
5704
 
5705
+ // src/monitor-interest.ts
5706
+ function normalizeAllowFrom(values) {
5707
+ const out = [];
5708
+ const seen = /* @__PURE__ */ new Set();
5709
+ for (const entry of values ?? []) {
5710
+ let id = String(entry).trim();
5711
+ if (!id) continue;
5712
+ if (id !== "*") {
5713
+ id = id.replace(/^(agent-wechat|wechat):/i, "").trim();
5714
+ }
5715
+ if (!id || seen.has(id)) continue;
5716
+ seen.add(id);
5717
+ out.push(id);
5718
+ }
5719
+ return out;
5720
+ }
5721
+ function buildMonitorInterest(account) {
5722
+ return {
5723
+ dmPolicy: account.dmPolicy ?? "disabled",
5724
+ dmAllowFrom: normalizeAllowFrom(account.allowFrom),
5725
+ groupPolicy: account.groupPolicy ?? "allowlist",
5726
+ groupAllowFrom: normalizeAllowFrom(account.groupAllowFrom),
5727
+ includeSystemFeeds: true
5728
+ };
5729
+ }
5730
+ function monitorInterestFingerprint(interest) {
5731
+ return JSON.stringify({
5732
+ dmPolicy: interest.dmPolicy,
5733
+ dmAllowFrom: [...interest.dmAllowFrom ?? []].sort(),
5734
+ groupPolicy: interest.groupPolicy ?? "allowlist",
5735
+ groupAllowFrom: [...interest.groupAllowFrom ?? []].sort(),
5736
+ includeSystemFeeds: interest.includeSystemFeeds !== false
5737
+ });
5738
+ }
5739
+ function shouldListChatsInterestOnly(interest) {
5740
+ return interest.dmPolicy === "allowlist" || interest.dmPolicy === "disabled";
5741
+ }
5742
+ async function ensureMonitorInterestSynced(client, account, log) {
5743
+ const interest = buildMonitorInterest(account);
5744
+ const desiredFp = monitorInterestFingerprint(interest);
5745
+ try {
5746
+ const current = await client.getMonitorInterest();
5747
+ const serverInterest = current.interest;
5748
+ if (serverInterest && monitorInterestFingerprint(serverInterest) === desiredFp) {
5749
+ return { interest, synced: true };
5750
+ }
5751
+ await client.setMonitorInterest(interest);
5752
+ log?.info?.(
5753
+ `[wechat:${account.accountId}] Synced monitor interest to agent-server (dmPolicy=${interest.dmPolicy}, dmAllowFrom=${interest.dmAllowFrom.length})`
5754
+ );
5755
+ return { interest, synced: true };
5756
+ } catch (err) {
5757
+ log?.error?.(
5758
+ `[wechat:${account.accountId}] Failed to sync monitor interest: ${err}`
5759
+ );
5760
+ return { interest, synced: false };
5761
+ }
5762
+ }
5763
+
5674
5764
  // src/catch-up.ts
5675
5765
  function recoveryCursor(selection) {
5676
5766
  return selection.cursor;
@@ -5824,7 +5914,7 @@ async function listInitialMonitorChatSnapshot(client, state, pageBudget = DEFAUL
5824
5914
  const chats = [];
5825
5915
  let cursor;
5826
5916
  for (let pages = 0; pages < pageBudget; pages += 1) {
5827
- const page = await client.listChatsPage(MONITOR_CHAT_PAGE_LIMIT, cursor);
5917
+ const page = await client.listChatsPage(MONITOR_CHAT_PAGE_LIMIT, cursor, void 0, state.interestOnly);
5828
5918
  chats.push(...page.items);
5829
5919
  cursor = page.nextCursor;
5830
5920
  if (!cursor) {
@@ -5837,7 +5927,7 @@ async function listInitialMonitorChatSnapshot(client, state, pageBudget = DEFAUL
5837
5927
  }
5838
5928
  async function listNextMonitorChatPage(client, state) {
5839
5929
  const isInitialSnapshot = !state.initialScanComplete;
5840
- const page = await client.listChatsPage(MONITOR_CHAT_PAGE_LIMIT, state.cursor);
5930
+ const page = await client.listChatsPage(MONITOR_CHAT_PAGE_LIMIT, state.cursor, void 0, state.interestOnly);
5841
5931
  state.cursor = page.nextCursor;
5842
5932
  if (!page.nextCursor) {
5843
5933
  state.initialScanComplete = true;
@@ -6520,9 +6610,12 @@ function safeInboundFilename(raw) {
6520
6610
  const cleaned = [...base].filter((char) => char >= " " && char !== "\x7F" && char !== "/" && char !== "\\").slice(0, MAX_FILENAME_CHARS).join("").replace(/^[.\s]+|[.\s]+$/g, "");
6521
6611
  return cleaned || void 0;
6522
6612
  }
6523
- var SUPPORTED_FILE_FORMATS = /* @__PURE__ */ new Set(["pdf"]);
6613
+ var SUPPORTED_FILE_FORMATS = /* @__PURE__ */ new Set(["pdf", "docx"]);
6524
6614
  function documentMimeFromMagic(buf) {
6525
6615
  if (buf.length >= 5 && buf.toString("ascii", 0, 5) === "%PDF-") return "application/pdf";
6616
+ if (buf.length >= 4 && buf[0] === 80 && buf[1] === 75 && (buf[2] === 3 || buf[2] === 5 || buf[2] === 7) && buf[3] === 4 && buf.includes(Buffer.from("word/document.xml", "ascii"))) {
6617
+ return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
6618
+ }
6526
6619
  return void 0;
6527
6620
  }
6528
6621
  function binaryMimeFromMagic(buf) {
@@ -6778,7 +6871,11 @@ async function validateInboundMedia(result) {
6778
6871
  if (buffer.length > MAX_FILE_BYTES) return { ok: false, code: "MEDIA_FILE_TOO_LARGE" };
6779
6872
  if (!safeInboundFilename(result.filename)) return { ok: false, code: "MEDIA_FILENAME_INVALID" };
6780
6873
  if (!SUPPORTED_FILE_FORMATS.has(format)) return { ok: false, code: "MEDIA_FILE_TYPE_MISMATCH" };
6781
- if (documentMimeFromMagic(buffer) !== "application/pdf") {
6874
+ const detected = documentMimeFromMagic(buffer);
6875
+ if (format === "pdf" && detected !== "application/pdf") {
6876
+ return { ok: false, code: "MEDIA_FILE_TYPE_MISMATCH" };
6877
+ }
6878
+ if (format === "docx" && detected !== "application/vnd.openxmlformats-officedocument.wordprocessingml.document") {
6782
6879
  return { ok: false, code: "MEDIA_FILE_TYPE_MISMATCH" };
6783
6880
  }
6784
6881
  }
@@ -6787,20 +6884,71 @@ async function validateInboundMedia(result) {
6787
6884
  function safeBodyAfterKnownMediaFailure(baseType, content) {
6788
6885
  return baseType === 3 ? "[Image unavailable]" : content;
6789
6886
  }
6887
+ function formatType49MediaFailureBody(content, mediaErrorCode) {
6888
+ const text = content || "File attachment";
6889
+ return `${text}
6890
+ [Attachment unavailable: ${mediaErrorCode}]`;
6891
+ }
6790
6892
 
6791
6893
  // src/inbound-media-poll.ts
6894
+ var DOWNLOADABLE_MEDIA_TYPES = /* @__PURE__ */ new Set([3, 34, 43]);
6895
+ var CHAT_HISTORY_APPMSG_TYPE = 19;
6896
+ function isWeChatChatHistoryMessage(msg) {
6897
+ if (msg.forwarded) return true;
6898
+ const content = msg.content ?? "";
6899
+ if (content.startsWith("[Chat History]")) return true;
6900
+ const baseType = msg.type & 2147483647;
6901
+ if (baseType !== 49) return false;
6902
+ const packedSubtype = Math.floor(msg.type / 4294967296);
6903
+ if (packedSubtype === CHAT_HISTORY_APPMSG_TYPE) return true;
6904
+ return /<type>\s*19\s*<\/type>/.test(content);
6905
+ }
6906
+ function shouldPollInboundMedia(msg) {
6907
+ const baseType = msg.type & 2147483647;
6908
+ if (DOWNLOADABLE_MEDIA_TYPES.has(baseType)) return true;
6909
+ return baseType === 49;
6910
+ }
6911
+ function nestedChatHistoryMedia(result) {
6912
+ return (result?.items ?? []).filter(
6913
+ (item) => Boolean(item?.data) && Boolean(item?.type) && item.type !== "unsupported"
6914
+ );
6915
+ }
6916
+ function isUnsupportedInboundMedia(result) {
6917
+ return result?.type === "unsupported";
6918
+ }
6919
+ function mediaFlagsFromPollResult(result, baseType) {
6920
+ if (nestedChatHistoryMedia(result).length > 0) {
6921
+ return { hasMedia: true };
6922
+ }
6923
+ if (result && result.data && result.type !== "unsupported") {
6924
+ return { hasMedia: true };
6925
+ }
6926
+ if (isUnsupportedInboundMedia(result)) {
6927
+ return { hasMedia: false };
6928
+ }
6929
+ if (result?.errorCode || result && result.type !== "unsupported" || DOWNLOADABLE_MEDIA_TYPES.has(baseType)) {
6930
+ return {
6931
+ hasMedia: true,
6932
+ mediaErrorCode: result?.errorCode ?? "MEDIA_DOWNLOAD_UNAVAILABLE"
6933
+ };
6934
+ }
6935
+ return { hasMedia: false };
6936
+ }
6792
6937
  var RETRYABLE_MEDIA_ERRORS = /* @__PURE__ */ new Set([
6793
6938
  "MEDIA_NOT_DOWNLOADED",
6794
6939
  "IMAGE_RESOURCE_UNAVAILABLE",
6795
6940
  "FILE_NOT_DOWNLOADED",
6796
6941
  "FILE_NOT_STABLE",
6797
6942
  "VOICE_NOT_DOWNLOADED",
6798
- "IMAGE_NOT_STABLE"
6943
+ "IMAGE_NOT_STABLE",
6944
+ "CHAT_HISTORY_NOT_MATERIALIZED"
6799
6945
  ]);
6800
6946
  var DEFAULT_MEDIA_POLL_ATTEMPTS = 6;
6801
6947
  var DEFAULT_MEDIA_POLL_INTERVAL_MS = 500;
6948
+ var CHAT_HISTORY_MEDIA_POLL_INTERVAL_MS = 1e3;
6802
6949
  var IMAGE_MATERIALIZATION_OPEN_CHAT_TIMEOUT_MS = 400;
6803
6950
  var FILE_MATERIALIZATION_CLICK_TIMEOUT_MS = 8e3;
6951
+ var CHAT_HISTORY_MATERIALIZATION_CLICK_TIMEOUT_MS = 25e3;
6804
6952
  function shouldTriggerMediaMaterialization(result) {
6805
6953
  return result.type === "image" || result.type === "file" || result.type === "pending";
6806
6954
  }
@@ -6834,9 +6982,58 @@ function triggerFileBubbleClick(client, chatId, result, attempt, options) {
6834
6982
  const clicked = Promise.resolve(client.downloadFile(chatId, filename, void 0, timeoutMs));
6835
6983
  void clicked.catch(() => void 0);
6836
6984
  }
6985
+ function shouldTriggerChatHistoryMaterialization(result) {
6986
+ if (result.errorCode === "CHAT_HISTORY_NOT_MATERIALIZED") return true;
6987
+ if (result.type === "pending" && result.errorCode === "IMAGE_RESOURCE_UNAVAILABLE") return true;
6988
+ if (result.type === "pending" && !result.items?.some((item) => item?.data)) return true;
6989
+ return false;
6990
+ }
6991
+ function triggerChatHistoryMaterialize(client, chatId, result, attempt, options) {
6992
+ const timeoutMs = options?.timeoutMs ?? CHAT_HISTORY_MATERIALIZATION_CLICK_TIMEOUT_MS;
6993
+ options?.log?.info?.(
6994
+ `[wechat:media] triggering chat-history materialize attempt=${attempt} title=${options?.title ?? ""} localId=${options?.localId ?? ""} skipOpen=${options?.skipOpen === true}`
6995
+ );
6996
+ if (!client.materializeChatHistory) {
6997
+ throw Object.assign(new Error("CHAT_HISTORY_MATERIALIZE_TRIGGER_UNAVAILABLE"), {
6998
+ code: "CHAT_HISTORY_MATERIALIZE_TRIGGER_UNAVAILABLE"
6999
+ });
7000
+ }
7001
+ const clicked = Promise.resolve(
7002
+ client.materializeChatHistory(
7003
+ chatId,
7004
+ {
7005
+ title: options?.title,
7006
+ localId: options?.localId,
7007
+ executionTimeoutMs: timeoutMs
7008
+ },
7009
+ void 0
7010
+ )
7011
+ );
7012
+ void clicked.catch(() => void 0);
7013
+ }
7014
+ function createChatHistoryMaterializationTrigger(client, chatId, options) {
7015
+ return async (result, attempt) => {
7016
+ if (!shouldTriggerChatHistoryMaterialization(result) || !options?.title?.trim()) return;
7017
+ triggerChatHistoryMaterialize(client, chatId, result, attempt, options);
7018
+ };
7019
+ }
7020
+ function chatHistoryTitleFromMessage(msg) {
7021
+ const fromForwarded = msg.forwarded?.title?.trim();
7022
+ if (fromForwarded) return fromForwarded;
7023
+ const content = msg.content ?? "";
7024
+ const en = content.match(/^\[Chat History\]\s*([^\n]+)/);
7025
+ if (en?.[1]?.trim()) return en[1].trim();
7026
+ const zh = content.match(/^\[聊天记录\]\s*([^\n]+)/);
7027
+ return zh?.[1]?.trim() || void 0;
7028
+ }
6837
7029
  function createMediaMaterializationTrigger(client, chatId, options) {
6838
7030
  const timeoutMs = options?.timeoutMs ?? IMAGE_MATERIALIZATION_OPEN_CHAT_TIMEOUT_MS;
6839
7031
  return async (result, attempt) => {
7032
+ if (result.errorCode === "CHAT_HISTORY_NOT_MATERIALIZED") {
7033
+ if (!options?.title?.trim()) return;
7034
+ triggerChatHistoryMaterialize(client, chatId, result, attempt, options);
7035
+ return;
7036
+ }
6840
7037
  if (!shouldTriggerMediaMaterialization(result)) return;
6841
7038
  if (isFileMaterialization(result)) {
6842
7039
  triggerFileBubbleClick(client, chatId, result, attempt, options);
@@ -6847,6 +7044,16 @@ function createMediaMaterializationTrigger(client, chatId, options) {
6847
7044
  }
6848
7045
  function mediaMaterializationTriggerForMessage(opts) {
6849
7046
  const baseType = opts.messageType & 2147483647;
7047
+ if (opts.chatHistory) {
7048
+ const extra = typeof opts.chatHistory === "object" ? opts.chatHistory : {};
7049
+ return createChatHistoryMaterializationTrigger(opts.client, opts.chatId, {
7050
+ log: opts.log,
7051
+ timeoutMs: opts.timeoutMs ?? CHAT_HISTORY_MATERIALIZATION_CLICK_TIMEOUT_MS,
7052
+ skipOpen: opts.skipOpen,
7053
+ title: extra.title,
7054
+ localId: extra.localId
7055
+ });
7056
+ }
6850
7057
  if (baseType !== 3 && baseType !== 49) return void 0;
6851
7058
  return createMediaMaterializationTrigger(opts.client, opts.chatId, {
6852
7059
  log: opts.log,
@@ -6878,6 +7085,15 @@ function isRetryable(result) {
6878
7085
  if (result.errorCode !== void 0) return RETRYABLE_MEDIA_ERRORS.has(result.errorCode);
6879
7086
  return result.type === "pending";
6880
7087
  }
7088
+ function chatHistoryMediaPollWindow(opts) {
7089
+ const intervalMs = Math.max(1, opts?.intervalMs ?? CHAT_HISTORY_MEDIA_POLL_INTERVAL_MS);
7090
+ const timeoutMs = opts?.timeoutMs ?? CHAT_HISTORY_MATERIALIZATION_CLICK_TIMEOUT_MS;
7091
+ const maxAttempts = Math.max(
7092
+ DEFAULT_MEDIA_POLL_ATTEMPTS,
7093
+ Math.ceil(timeoutMs / intervalMs) + 1
7094
+ );
7095
+ return { maxAttempts, intervalMs };
7096
+ }
6881
7097
  async function pollMedia(client, chatId, localId, log, maxAttempts = DEFAULT_MEDIA_POLL_ATTEMPTS, intervalMs = DEFAULT_MEDIA_POLL_INTERVAL_MS, onRetryTrigger) {
6882
7098
  let lastResult;
6883
7099
  let triggerUsed = false;
@@ -7159,6 +7375,9 @@ function loadFile(path) {
7159
7375
  function inboundEventId(accountId, chatId, message) {
7160
7376
  return createHash("sha256").update(`${accountId}\0${chatId}\0${cursorMessageKey(message)}`).digest("hex");
7161
7377
  }
7378
+ function nestedMediaEventId(parentEventId, item) {
7379
+ return createHash("sha256").update(JSON.stringify(["nested-media", parentEventId, item.type ?? "", item.filename ?? ""])).digest("hex");
7380
+ }
7162
7381
  var InboundEventLedger = class {
7163
7382
  path;
7164
7383
  entries = /* @__PURE__ */ new Map();
@@ -7533,9 +7752,29 @@ function applyEmptyUnreadSkip(chatId, opts) {
7533
7752
  opts.backoff.set(chatId, { nextRetryAt: now + backoffMs, retryCount });
7534
7753
  return { seededLastSeen, backoffMs };
7535
7754
  }
7755
+ function stickyUnreadTip(chat) {
7756
+ return `${chat.lastMsgLocalId ?? ""}:${chat.unreadCount ?? 0}`;
7757
+ }
7758
+ function isStickyUnclearedAcked(chat, acked) {
7759
+ const chatId = chat.username ?? chat.id;
7760
+ return acked.get(chatId) === stickyUnreadTip(chat);
7761
+ }
7762
+ function ackStickyUnclearedUnread(chatId, chat, acked, backoff, now = Date.now()) {
7763
+ const tip = stickyUnreadTip(chat);
7764
+ acked.set(chatId, tip);
7765
+ const retryCount = (backoff.get(chatId)?.retryCount ?? 0) + 1;
7766
+ const backoffMs = Math.min(
7767
+ EMPTY_UNREAD_BACKOFF_MAX_MS,
7768
+ EMPTY_UNREAD_BACKOFF_MS * 2 ** Math.min(retryCount - 1, 6)
7769
+ );
7770
+ backoff.set(chatId, { nextRetryAt: now + backoffMs, retryCount });
7771
+ return { tip, backoffMs };
7772
+ }
7773
+ function isOpenImChat(chatId) {
7774
+ return chatId.includes("@openim");
7775
+ }
7536
7776
 
7537
7777
  // src/monitor.ts
7538
- var MEDIA_TYPES = /* @__PURE__ */ new Set([3, 34, 43]);
7539
7778
  var HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]";
7540
7779
  var CURRENT_MESSAGE_MARKER = "[Current message - respond to this]";
7541
7780
  function sleep(ms, signal) {
@@ -7589,6 +7828,7 @@ async function startWeChatMonitor(opts) {
7589
7828
  };
7590
7829
  trimPendingResetRetries(retryState);
7591
7830
  const newsappHandled = /* @__PURE__ */ new Map();
7831
+ const stickyUnreadAck = /* @__PURE__ */ new Map();
7592
7832
  const groupHistory = /* @__PURE__ */ new Map();
7593
7833
  const GROUP_HISTORY_LIMIT = 50;
7594
7834
  let lastAuthCheck = 0;
@@ -7604,6 +7844,13 @@ async function startWeChatMonitor(opts) {
7604
7844
  while (!abortSignal.aborted) {
7605
7845
  try {
7606
7846
  const cfg = getWeChatRuntime().config.current();
7847
+ const liveAccountForInterest = resolveWeChatAccount(cfg, account.accountId) ?? account;
7848
+ const { interest: nextInterest, synced: interestSynced } = await ensureMonitorInterestSynced(
7849
+ client,
7850
+ liveAccountForInterest,
7851
+ log
7852
+ );
7853
+ chatScanState.interestOnly = shouldListChatsInterestOnly(nextInterest) && interestSynced;
7607
7854
  const now = Date.now();
7608
7855
  if (now - lastMediaCleanup >= MEDIA_RETENTION_MS / 4) {
7609
7856
  lastMediaCleanup = now;
@@ -7689,6 +7936,7 @@ async function startWeChatMonitor(opts) {
7689
7936
  const unreadChats = chats.filter((c) => {
7690
7937
  const chatId = c.username ?? c.id;
7691
7938
  if (isEmptyUnreadBackoffActive(emptyUnreadBackoff, chatId)) return false;
7939
+ if (isStickyUnclearedAcked(c, stickyUnreadAck)) return false;
7692
7940
  if (c.unreadCount <= 0) return false;
7693
7941
  if (isNewsappChat(c)) return true;
7694
7942
  return !isOfficialAccount(chatId);
@@ -7703,16 +7951,23 @@ async function startWeChatMonitor(opts) {
7703
7951
  chats,
7704
7952
  retryState,
7705
7953
  Date.now(),
7706
- (chat) => !isNewsappChat(chat) || newsappHandled.get(chat.username ?? chat.id) !== `${chat.lastMsgLocalId ?? ""}:${chat.lastMessagePreview?.trim() ?? ""}`
7954
+ (chat) => {
7955
+ if (isStickyUnclearedAcked(chat, stickyUnreadAck)) return false;
7956
+ if (!isNewsappChat(chat)) return true;
7957
+ return newsappHandled.get(chat.username ?? chat.id) !== `${chat.lastMsgLocalId ?? ""}:${chat.lastMessagePreview?.trim() ?? ""}`;
7958
+ }
7707
7959
  );
7708
7960
  for (const chatId of chatsToProcess.keys()) {
7709
7961
  if (isOfficialAccount(chatId) && chatId !== "newsapp" || isEmptyUnreadBackoffActive(emptyUnreadBackoff, chatId)) {
7710
7962
  chatsToProcess.delete(chatId);
7711
7963
  }
7712
7964
  }
7713
- if (unreadChats.length > 0) {
7965
+ const actionableUnread = unreadChats.filter(
7966
+ (c) => chatsToProcess.has(c.username ?? c.id)
7967
+ );
7968
+ if (actionableUnread.length > 0) {
7714
7969
  log?.info?.(
7715
- `[wechat:${account.accountId}] ${unreadChats.length} chat(s) with unreads`
7970
+ `[wechat:${account.accountId}] ${actionableUnread.length} chat(s) with unreads`
7716
7971
  );
7717
7972
  }
7718
7973
  let deferredThisTick = 0;
@@ -7731,6 +7986,7 @@ async function startWeChatMonitor(opts) {
7731
7986
  inboundLedger,
7732
7987
  mediaPipeline,
7733
7988
  emptyUnreadBackoff,
7989
+ stickyUnreadAck,
7734
7990
  account,
7735
7991
  cfg,
7736
7992
  log,
@@ -7780,6 +8036,7 @@ async function startWeChatMonitor(opts) {
7780
8036
  inboundLedger,
7781
8037
  mediaPipeline,
7782
8038
  emptyUnreadBackoff,
8039
+ stickyUnreadAck,
7783
8040
  account,
7784
8041
  cfg,
7785
8042
  log,
@@ -7849,30 +8106,75 @@ async function prepareMessage(client, mediaPipeline, msg, chatId, chat, liveAcco
7849
8106
  let mediaMime;
7850
8107
  let mediaHash;
7851
8108
  let mediaPreviewPath;
8109
+ let mediaPaths;
8110
+ let mediaMimes;
7852
8111
  let mediaSource;
7853
8112
  let mediaErrorCode;
7854
8113
  let hasMedia = false;
7855
8114
  const baseType = msg.type & 2147483647;
7856
- const mayHaveMedia = MEDIA_TYPES.has(baseType) || baseType === 49;
8115
+ const isChatHistory = isWeChatChatHistoryMessage(msg);
8116
+ const mayHaveMedia = shouldPollInboundMedia(msg);
7857
8117
  if (mayHaveMedia) {
7858
8118
  log?.info?.(`[wechat:${liveAccount.accountId}] Checking media for msg ${msg.localId} (type ${baseType})`);
7859
8119
  try {
8120
+ const chatHistoryPoll = isChatHistory ? chatHistoryMediaPollWindow() : void 0;
7860
8121
  const result = await pollMedia(
7861
8122
  client,
7862
8123
  chatId,
7863
8124
  msg.localId,
7864
8125
  log,
7865
- void 0,
7866
- void 0,
8126
+ chatHistoryPoll?.maxAttempts,
8127
+ chatHistoryPoll?.intervalMs,
7867
8128
  mediaMaterializationTriggerForMessage({
7868
8129
  client,
7869
8130
  chatId,
7870
8131
  messageType: baseType,
7871
8132
  log,
7872
- skipOpen
8133
+ skipOpen,
8134
+ chatHistory: isChatHistory ? {
8135
+ title: chatHistoryTitleFromMessage(msg),
8136
+ localId: msg.localId
8137
+ } : void 0
7873
8138
  })
7874
8139
  );
7875
- if (result && result.data && result.type !== "unsupported") {
8140
+ const nestedItems = nestedChatHistoryMedia(result);
8141
+ if (nestedItems.length > 0) {
8142
+ const savedPaths = [];
8143
+ const savedMimes = [];
8144
+ for (const [index, item] of nestedItems.entries()) {
8145
+ const saved = await mediaPipeline.process(
8146
+ item,
8147
+ {
8148
+ eventId: nestedMediaEventId(inboundEventId(liveAccount.accountId, chatId, msg), item),
8149
+ chatId,
8150
+ localId: msg.localId
8151
+ },
8152
+ async (buffer, mime, filename) => core.channel.media.saveMediaBuffer(buffer, mime, "inbound", void 0, filename),
8153
+ async (buffer, mime, filename) => core.channel.media.saveMediaBuffer(buffer, mime, "inbound", void 0, filename),
8154
+ async (path) => unlink(path)
8155
+ );
8156
+ if (!saved.ok) {
8157
+ log?.error?.(`[wechat:media] nested chat-history media rejected index=${index} code=${saved.code}`);
8158
+ continue;
8159
+ }
8160
+ if (!saved.originalPath || savedPaths.includes(saved.originalPath)) continue;
8161
+ savedPaths.push(saved.originalPath);
8162
+ savedMimes.push(saved.mime);
8163
+ if (!mediaPath) {
8164
+ mediaPath = saved.originalPath;
8165
+ mediaMime = saved.mime;
8166
+ mediaHash = saved.hash;
8167
+ mediaPreviewPath = saved.previewPath;
8168
+ mediaSource = item.source;
8169
+ }
8170
+ }
8171
+ if (savedPaths.length > 0) {
8172
+ hasMedia = true;
8173
+ mediaPaths = savedPaths;
8174
+ mediaMimes = savedMimes;
8175
+ log?.info?.(`[wechat:media] nested chat-history media saved count=${savedPaths.length}`);
8176
+ }
8177
+ } else if (result && result.data && result.type !== "unsupported") {
7876
8178
  hasMedia = true;
7877
8179
  mediaSource = result.source;
7878
8180
  log?.info?.(`[wechat:media] inbound media received type=${result.type} format=${result.format}`);
@@ -7893,15 +8195,22 @@ async function prepareMessage(client, mediaPipeline, msg, chatId, chat, liveAcco
7893
8195
  mediaPreviewPath = saved.previewPath;
7894
8196
  log?.info?.(`[wechat:media] inbound media saved hash=${saved.hash}${saved.previewPath ? " preview=present" : ""}`);
7895
8197
  }
7896
- } else if (result?.errorCode || result && result.type !== "unsupported" || MEDIA_TYPES.has(baseType)) {
7897
- hasMedia = true;
7898
- mediaErrorCode = result?.errorCode ?? "MEDIA_DOWNLOAD_UNAVAILABLE";
7899
- log?.info?.(`[wechat:media] inbound media unavailable code=${mediaErrorCode}`);
8198
+ } else {
8199
+ const flags = mediaFlagsFromPollResult(result, baseType);
8200
+ hasMedia = flags.hasMedia;
8201
+ mediaErrorCode = isChatHistory ? void 0 : flags.mediaErrorCode;
8202
+ if (mediaErrorCode) {
8203
+ log?.info?.(`[wechat:media] inbound media unavailable code=${mediaErrorCode}`);
8204
+ }
7900
8205
  }
7901
8206
  } catch (err) {
7902
- hasMedia = true;
7903
- mediaErrorCode = "MEDIA_DOWNLOAD_FAILED";
7904
- log?.error?.("[wechat:media] inbound media failed code=MEDIA_DOWNLOAD_FAILED");
8207
+ if (isChatHistory) {
8208
+ log?.error?.("[wechat:media] nested chat-history media failed code=MEDIA_DOWNLOAD_FAILED");
8209
+ } else {
8210
+ hasMedia = true;
8211
+ mediaErrorCode = "MEDIA_DOWNLOAD_FAILED";
8212
+ log?.error?.("[wechat:media] inbound media failed code=MEDIA_DOWNLOAD_FAILED");
8213
+ }
7905
8214
  }
7906
8215
  }
7907
8216
  const timestamp = new Date(msg.timestamp).getTime();
@@ -7918,10 +8227,8 @@ async function prepareMessage(client, mediaPipeline, msg, chatId, chat, liveAcco
7918
8227
  } else if (!mediaMime.startsWith("image/") && !mediaMime.startsWith("audio/")) {
7919
8228
  rawBody = `[File: ${rawBody}]`;
7920
8229
  }
7921
- } else if (mediaErrorCode && baseType === 49) {
7922
- const text = msg.content || "File attachment";
7923
- rawBody = `${text}
7924
- [Attachment unavailable: ${mediaErrorCode}]`;
8230
+ } else if (mediaErrorCode && baseType === 49 && !isChatHistory) {
8231
+ rawBody = formatType49MediaFailureBody(msg.content || "", mediaErrorCode);
7925
8232
  }
7926
8233
  if (mediaSource === "thumbnail") {
7927
8234
  rawBody = rawBody ? `${rawBody}
@@ -7950,6 +8257,8 @@ ${replyBlock}` : replyBlock;
7950
8257
  mediaMime,
7951
8258
  mediaHash,
7952
8259
  mediaPreviewPath,
8260
+ mediaPaths,
8261
+ mediaMimes,
7953
8262
  senderName,
7954
8263
  senderId,
7955
8264
  isGroup,
@@ -7981,9 +8290,11 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
7981
8290
  const core = getWeChatRuntime();
7982
8291
  const lastMsg = segment[segment.length - 1];
7983
8292
  const { isGroup, senderId, senderName, timestamp, rawBody, commandBody, msg } = lastMsg;
7984
- const mediaMsg = segment.find((pm) => pm.mediaPath);
7985
- const mediaPath = mediaMsg?.mediaPath;
7986
- const mediaMime = mediaMsg?.mediaMime;
8293
+ const mediaMsg = segment.find((pm) => pm.mediaPath || pm.mediaPaths && pm.mediaPaths.length > 0);
8294
+ const mediaPaths = mediaMsg?.mediaPaths?.length ? mediaMsg.mediaPaths : mediaMsg?.mediaPath ? [mediaMsg.mediaPath] : [];
8295
+ const mediaMimes = mediaMsg?.mediaMimes?.length ? mediaMsg.mediaMimes : mediaMsg?.mediaMime ? [mediaMsg.mediaMime] : [];
8296
+ const mediaPath = mediaPaths[0];
8297
+ const mediaMime = mediaMimes[0];
7987
8298
  log?.info?.(
7988
8299
  `[wechat:${liveAccount.accountId}] Dispatching segment: ${segment.length} msg(s), last=${msg.localId}${mediaPath ? " media=present" : ""}`
7989
8300
  );
@@ -8119,9 +8430,9 @@ async function dispatchSegment(segment, client, chatId, chat, liveAccount, polic
8119
8430
  MediaPath: mediaPath,
8120
8431
  MediaUrl: mediaPath,
8121
8432
  MediaType: mediaMime,
8122
- MediaPaths: [mediaPath],
8123
- MediaUrls: [mediaPath],
8124
- MediaTypes: mediaMime ? [mediaMime] : [],
8433
+ MediaPaths: mediaPaths,
8434
+ MediaUrls: mediaPaths,
8435
+ MediaTypes: mediaMimes,
8125
8436
  ...mediaMsg?.mediaHash ? { MediaHash: mediaMsg.mediaHash } : {},
8126
8437
  ...mediaMsg?.mediaPreviewPath ? { MediaPreviewPath: mediaMsg.mediaPreviewPath } : {}
8127
8438
  } : {},
@@ -8250,7 +8561,7 @@ function bufferGroupHistory(groupHistory, chatId, pm, limit) {
8250
8561
  if (first) groupHistory.delete(first);
8251
8562
  }
8252
8563
  }
8253
- async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHandled, startupBaselines, pendingMessageScans, persistRetryState, inboundLedger, mediaPipeline, emptyUnreadBackoff, account, cfg, log, skipOpen, groupHistory, groupHistoryLimit, catchup) {
8564
+ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHandled, startupBaselines, pendingMessageScans, persistRetryState, inboundLedger, mediaPipeline, emptyUnreadBackoff, stickyUnreadAck, account, cfg, log, skipOpen, groupHistory, groupHistoryLimit, catchup) {
8254
8565
  const core = getWeChatRuntime();
8255
8566
  const liveAccount = resolveWeChatAccount(cfg, account.accountId) ?? account;
8256
8567
  const chatId = chat.username ?? chat.id;
@@ -8417,11 +8728,26 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
8417
8728
  );
8418
8729
  }
8419
8730
  if (chat.unreadCount > 0 && !isNewsappChat(chat)) {
8420
- await openChatIfNeeded();
8731
+ const sticky = ackStickyUnclearedUnread(
8732
+ chatId,
8733
+ chat,
8734
+ stickyUnreadAck,
8735
+ emptyUnreadBackoff
8736
+ );
8737
+ log?.info?.(
8738
+ `[wechat:${liveAccount.accountId}] ${chatId}: ignoring sticky unread after catch-up (unreadCount=${chat.unreadCount}, tip=${sticky.tip}, openim=${isOpenImChat(chatId)}); badge kept; backoff=${sticky.backoffMs}ms`
8739
+ );
8421
8740
  }
8422
8741
  return "skipped";
8423
8742
  }
8424
- if (!isNewsappChat(chat)) await openChatIfNeeded();
8743
+ const anyAllowlistedInbound = newMessages.some((msg) => {
8744
+ if (msg.isSelf) return false;
8745
+ if (shouldBypassNewsappAuthorization(chat)) return true;
8746
+ const isGroup2 = chatId.includes("@chatroom");
8747
+ const senderId = msg.sender ?? chatId;
8748
+ return resolveWeChatInboundAccessDecision({ isGroup: isGroup2, senderId, policy }).allowed;
8749
+ });
8750
+ if (anyAllowlistedInbound && !isNewsappChat(chat)) await openChatIfNeeded();
8425
8751
  const catchUpLimits = {
8426
8752
  maxMessages: liveAccount.catchUpMaxMessages,
8427
8753
  maxAgeMs: liveAccount.catchUpMaxAgeMs
@@ -8517,6 +8843,8 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
8517
8843
  ...processed[i],
8518
8844
  mediaPath: void 0,
8519
8845
  mediaMime: void 0,
8846
+ mediaPaths: void 0,
8847
+ mediaMimes: void 0,
8520
8848
  hasMedia: false
8521
8849
  };
8522
8850
  }
@@ -8618,6 +8946,17 @@ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHand
8618
8946
  }
8619
8947
  const maxId = Math.max(...newMessages.map((m) => m.localId));
8620
8948
  advanceLastSeen(newMessages.filter((message) => message.localId <= maxId));
8949
+ if (chat.unreadCount > 0 && !isNewsappChat(chat)) {
8950
+ const sticky = ackStickyUnclearedUnread(
8951
+ chatId,
8952
+ chat,
8953
+ stickyUnreadAck,
8954
+ emptyUnreadBackoff
8955
+ );
8956
+ log?.info?.(
8957
+ `[wechat:${liveAccount.accountId}] ${chatId}: ignoring sticky unread after filtered window (tip=${sticky.tip}, openim=${isOpenImChat(chatId)}); badge kept; backoff=${sticky.backoffMs}ms`
8958
+ );
8959
+ }
8621
8960
  return "skipped";
8622
8961
  }
8623
8962
 
@@ -9295,7 +9634,7 @@ var wechatPlugin = {
9295
9634
  allowFrom: account.allowFrom ?? [],
9296
9635
  allowFromPath: `channels.${OPENCLAW_CHANNEL_ID}.allowFrom`,
9297
9636
  policyPath: `channels.${OPENCLAW_CHANNEL_ID}.dmPolicy`,
9298
- approveHint: `Add the wxid to channels.${OPENCLAW_CHANNEL_ID}.allowFrom`,
9637
+ approveHint: `Add the wxid or enterprise @openim id to channels.${OPENCLAW_CHANNEL_ID}.allowFrom`,
9299
9638
  normalizeEntry: (raw) => raw.replace(/^(agent-wechat|wechat):/i, "").trim()
9300
9639
  })
9301
9640
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyan-du/agent-wechat-openclaw",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",