@kyan-du/agent-wechat-openclaw 0.12.0-next.0 → 0.12.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.
Files changed (2) hide show
  1. package/dist/index.js +672 -37
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -5592,6 +5592,248 @@ function selectCatchUpMessages(messages, limits, mode = "latest") {
5592
5592
  };
5593
5593
  }
5594
5594
 
5595
+ // src/monitor-cursor.ts
5596
+ function cursorMessageKey(message) {
5597
+ return `${message.serverId ?? ""}:${message.timestamp}:${message.type}:${message.sender ?? ""}:${message.content ?? ""}`;
5598
+ }
5599
+ function markEqualCursorUnreadHandled(chatId, message, equalCursorUnreadHandled) {
5600
+ if (message === void 0) return;
5601
+ equalCursorUnreadHandled.set(chatId, {
5602
+ localId: message.localId,
5603
+ messageKey: cursorMessageKey(message)
5604
+ });
5605
+ }
5606
+ function markCursorMessagesHandled(chatId, messages, equalCursorUnreadHandled) {
5607
+ if (messages.length === 0) return void 0;
5608
+ const cursorMessage = messages.reduce(
5609
+ (latest, message) => message.localId > latest.localId ? message : latest
5610
+ );
5611
+ markEqualCursorUnreadHandled(chatId, cursorMessage, equalCursorUnreadHandled);
5612
+ return cursorMessage.localId;
5613
+ }
5614
+ function selectCursorMessages(chatId, chat, messages, lastSeenId, equalCursorUnreadHandled, startupBaseline, generationResetConfirmed = false) {
5615
+ const firstPoll = !lastSeenId.has(chatId);
5616
+ const prevLastSeen = lastSeenId.get(chatId) ?? 0;
5617
+ const sorted = [...messages].sort((a, b) => a.localId - b.localId);
5618
+ const baselineActive = startupBaseline !== void 0 && (firstPoll || prevLastSeen < startupBaseline.localId);
5619
+ if (baselineActive) {
5620
+ const baselineRow = sorted.find((message) => message.localId === startupBaseline.localId);
5621
+ const generationReset = sorted.length > 0 && typeof chat.lastMsgLocalId === "number" && chat.lastMsgLocalId <= startupBaseline.localId && (baselineRow === void 0 && sorted.every((message) => message.localId < startupBaseline.localId) || baselineRow !== void 0 && startupBaseline.messageKey !== void 0 && cursorMessageKey(baselineRow) !== startupBaseline.messageKey);
5622
+ if (generationReset || generationResetConfirmed) {
5623
+ return {
5624
+ firstPoll,
5625
+ prevLastSeen,
5626
+ messages: sorted,
5627
+ generationReset: true
5628
+ };
5629
+ }
5630
+ const live = sorted.filter(
5631
+ (message) => message.localId > startupBaseline.localId || message.localId === startupBaseline.localId && startupBaseline.messageKey !== void 0 && cursorMessageKey(message) !== startupBaseline.messageKey
5632
+ );
5633
+ const seedLastSeen = sorted.filter((message) => message.localId <= startupBaseline.localId).at(-1)?.localId;
5634
+ return {
5635
+ firstPoll,
5636
+ prevLastSeen,
5637
+ messages: live,
5638
+ seedLastSeen
5639
+ };
5640
+ }
5641
+ if (firstPoll) {
5642
+ const unread2 = chat.unreadCount ?? 0;
5643
+ if (unread2 > 0 && unread2 < sorted.length) {
5644
+ return {
5645
+ firstPoll,
5646
+ prevLastSeen,
5647
+ messages: sorted.slice(-unread2),
5648
+ seedLastSeen: sorted[sorted.length - unread2 - 1].localId
5649
+ };
5650
+ }
5651
+ if (unread2 >= sorted.length) {
5652
+ return { firstPoll, prevLastSeen, messages: sorted };
5653
+ }
5654
+ return {
5655
+ firstPoll,
5656
+ prevLastSeen,
5657
+ messages: [],
5658
+ seedLastSeen: sorted[sorted.length - 1]?.localId
5659
+ };
5660
+ }
5661
+ const newer = sorted.filter((m) => m.localId > prevLastSeen);
5662
+ if (newer.length > 0) {
5663
+ return { firstPoll, prevLastSeen, messages: newer };
5664
+ }
5665
+ const unread = chat.unreadCount ?? 0;
5666
+ const handled = equalCursorUnreadHandled.get(chatId);
5667
+ const equalCursorRows = sorted.filter((message) => message.localId === prevLastSeen);
5668
+ const equalIdGenerationReset = unread > 0 && chat.lastMsgLocalId === prevLastSeen && sorted.every((message) => message.localId <= prevLastSeen) && equalCursorRows.length === 1 && handled?.localId === prevLastSeen && handled.messageKey !== cursorMessageKey(equalCursorRows[0]);
5669
+ const lowerIdGenerationReset = unread > 0 && sorted.length > 0 && sorted.every((message) => message.localId < prevLastSeen) && typeof chat.lastMsgLocalId === "number" && chat.lastMsgLocalId < prevLastSeen;
5670
+ if (lowerIdGenerationReset || equalIdGenerationReset || generationResetConfirmed) {
5671
+ return {
5672
+ firstPoll,
5673
+ prevLastSeen,
5674
+ messages: sorted,
5675
+ generationReset: true
5676
+ };
5677
+ }
5678
+ if (unread <= 0) {
5679
+ return { firstPoll, prevLastSeen, messages: [] };
5680
+ }
5681
+ const unreadSuffix = sorted.slice(-Math.min(unread, sorted.length));
5682
+ const equalCursorUnread = unreadSuffix.filter((m) => m.localId === prevLastSeen);
5683
+ const recoverable = equalCursorUnread.length === 1 && chat.lastMsgLocalId === prevLastSeen && (handled?.localId !== prevLastSeen || handled.messageKey !== cursorMessageKey(equalCursorUnread[0]));
5684
+ return {
5685
+ firstPoll,
5686
+ prevLastSeen,
5687
+ messages: recoverable ? equalCursorUnread : []
5688
+ };
5689
+ }
5690
+ function seedStartupBaselineFromChat(chat) {
5691
+ return typeof chat.lastMsgLocalId === "number" && chat.lastMsgLocalId > 0 ? { localId: chat.lastMsgLocalId } : void 0;
5692
+ }
5693
+ function enrichStartupBaselineFromMessages(baseline, messages) {
5694
+ if (baseline === void 0 || baseline.messageKey !== void 0) return baseline;
5695
+ const match = messages.find((message) => message.localId === baseline.localId);
5696
+ return match ? { ...baseline, messageKey: cursorMessageKey(match) } : baseline;
5697
+ }
5698
+ function selectMessagesHandledAfterDispatch(messages, successfulSegmentLastLocalIds) {
5699
+ if (successfulSegmentLastLocalIds.length === 0) return [];
5700
+ const lastHandled = Math.max(...successfulSegmentLastLocalIds);
5701
+ return messages.filter((message) => message.localId <= lastHandled);
5702
+ }
5703
+
5704
+ // src/monitor-scan.ts
5705
+ var MONITOR_CHAT_PAGE_LIMIT = 100;
5706
+ var MONITOR_MESSAGE_PAGE_LIMIT = 200;
5707
+ var DEFAULT_MESSAGE_PAGE_BUDGET = 50;
5708
+ var DEFAULT_INITIAL_CHAT_PAGE_BUDGET = 100;
5709
+ async function listInitialMonitorChatSnapshot(client, state, pageBudget = DEFAULT_INITIAL_CHAT_PAGE_BUDGET) {
5710
+ if (state.initialScanComplete) return [];
5711
+ const chats = [];
5712
+ let cursor;
5713
+ for (let pages = 0; pages < pageBudget; pages += 1) {
5714
+ const page = await client.listChatsPage(MONITOR_CHAT_PAGE_LIMIT, cursor);
5715
+ chats.push(...page.items);
5716
+ cursor = page.nextCursor;
5717
+ if (!cursor) {
5718
+ state.initialScanComplete = true;
5719
+ state.cursor = void 0;
5720
+ return chats;
5721
+ }
5722
+ }
5723
+ throw new Error(`initial chat snapshot exceeded ${pageBudget} pages`);
5724
+ }
5725
+ async function listNextMonitorChatPage(client, state) {
5726
+ const isInitialSnapshot = !state.initialScanComplete;
5727
+ const page = await client.listChatsPage(MONITOR_CHAT_PAGE_LIMIT, state.cursor);
5728
+ state.cursor = page.nextCursor;
5729
+ if (!page.nextCursor) {
5730
+ state.initialScanComplete = true;
5731
+ state.cursor = void 0;
5732
+ }
5733
+ return { chats: page.items, isInitialSnapshot };
5734
+ }
5735
+ function hasReachedStableMessageBoundary(messages, chat, firstPoll, prevLastSeen, startupBaseline) {
5736
+ if (messages.length === 0) return false;
5737
+ const sorted = [...messages].sort((a, b) => a.localId - b.localId);
5738
+ if (startupBaseline !== void 0 && (firstPoll || prevLastSeen < startupBaseline.localId)) {
5739
+ if (startupBaseline.localId <= 0) return true;
5740
+ if (sorted.some((message) => message.localId === startupBaseline.localId)) return true;
5741
+ return false;
5742
+ }
5743
+ if (firstPoll) {
5744
+ const unread2 = chat.unreadCount ?? 0;
5745
+ if (unread2 > 0) return messages.length >= unread2 + 1;
5746
+ return true;
5747
+ }
5748
+ if (sorted.some((message) => message.localId <= prevLastSeen)) return true;
5749
+ const unread = chat.unreadCount ?? 0;
5750
+ return unread > 0 && messages.length >= unread;
5751
+ }
5752
+ function detectsGenerationReset(messages, chat, firstPoll, prevLastSeen, startupBaseline, handledCursor) {
5753
+ if (messages.length === 0 || typeof chat.lastMsgLocalId !== "number") return false;
5754
+ const boundary = startupBaseline !== void 0 && (firstPoll || prevLastSeen < startupBaseline.localId) ? startupBaseline : handledCursor?.localId === prevLastSeen ? handledCursor : void 0;
5755
+ const boundaryLocalId = boundary?.localId ?? prevLastSeen;
5756
+ if (chat.lastMsgLocalId < boundaryLocalId) return true;
5757
+ if (chat.lastMsgLocalId !== boundaryLocalId || boundary?.messageKey === void 0) return false;
5758
+ const equalRow = messages.find((message) => message.localId === boundaryLocalId);
5759
+ return equalRow !== void 0 && cursorMessageKey(equalRow) !== boundary.messageKey;
5760
+ }
5761
+ function generationBoundaryIndex(messages) {
5762
+ for (let index = 1; index < messages.length; index += 1) {
5763
+ if (messages[index].localId >= messages[index - 1].localId) return index;
5764
+ }
5765
+ return void 0;
5766
+ }
5767
+ function shouldProbeGenerationReset(messages, chat, firstPoll, prevLastSeen, startupBaseline, handledCursor) {
5768
+ if (typeof chat.lastMsgLocalId !== "number") return false;
5769
+ const boundary = startupBaseline !== void 0 && (firstPoll || prevLastSeen < startupBaseline.localId) ? startupBaseline : handledCursor?.localId === prevLastSeen ? handledCursor : void 0;
5770
+ if (boundary === void 0 || chat.lastMsgLocalId > boundary.localId) return false;
5771
+ return !messages.some((message) => message.localId === boundary.localId);
5772
+ }
5773
+ async function listMessagesForMonitorCursor(client, chatId, options) {
5774
+ const pageBudget = options.pageBudget ?? DEFAULT_MESSAGE_PAGE_BUDGET;
5775
+ const messages = [...options.continuation?.messages ?? []];
5776
+ let cursor = options.continuation?.cursor;
5777
+ let generationReset = options.continuation?.generationReset ?? false;
5778
+ let pagesScanned = 0;
5779
+ while (pagesScanned < pageBudget) {
5780
+ const page = await client.listMessagesPage(chatId, MONITOR_MESSAGE_PAGE_LIMIT, cursor);
5781
+ pagesScanned += 1;
5782
+ messages.push(...page.items);
5783
+ cursor = page.nextCursor;
5784
+ generationReset ||= detectsGenerationReset(
5785
+ messages,
5786
+ options.chat,
5787
+ options.firstPoll,
5788
+ options.prevLastSeen,
5789
+ options.startupBaseline,
5790
+ options.handledCursor
5791
+ );
5792
+ if (generationReset) {
5793
+ const boundaryIndex = generationBoundaryIndex(messages);
5794
+ if (boundaryIndex !== void 0 || !cursor) {
5795
+ return {
5796
+ messages: boundaryIndex === void 0 ? messages : messages.slice(0, boundaryIndex),
5797
+ complete: true,
5798
+ nextCursor: void 0,
5799
+ pagesScanned,
5800
+ generationReset: true
5801
+ };
5802
+ }
5803
+ continue;
5804
+ }
5805
+ const probingReset = shouldProbeGenerationReset(
5806
+ messages,
5807
+ options.chat,
5808
+ options.firstPoll,
5809
+ options.prevLastSeen,
5810
+ options.startupBaseline,
5811
+ options.handledCursor
5812
+ );
5813
+ if (!cursor || !probingReset && hasReachedStableMessageBoundary(
5814
+ messages,
5815
+ options.chat,
5816
+ options.firstPoll,
5817
+ options.prevLastSeen,
5818
+ options.startupBaseline
5819
+ )) {
5820
+ return {
5821
+ messages,
5822
+ complete: true,
5823
+ nextCursor: void 0,
5824
+ pagesScanned
5825
+ };
5826
+ }
5827
+ }
5828
+ return {
5829
+ messages,
5830
+ complete: false,
5831
+ nextCursor: cursor,
5832
+ pagesScanned,
5833
+ generationReset: generationReset || void 0
5834
+ };
5835
+ }
5836
+
5595
5837
  // src/outbound.ts
5596
5838
  import { randomUUID } from "node:crypto";
5597
5839
  async function sendLogicalMediaTask(opts) {
@@ -5623,6 +5865,255 @@ async function sendLogicalMediaTask(opts) {
5623
5865
  return requestId;
5624
5866
  }
5625
5867
 
5868
+ // src/monitor-retry.ts
5869
+ import {
5870
+ existsSync,
5871
+ closeSync,
5872
+ mkdirSync,
5873
+ openSync,
5874
+ readFileSync,
5875
+ renameSync,
5876
+ rmSync,
5877
+ writeFileSync,
5878
+ fsyncSync
5879
+ } from "node:fs";
5880
+ import { homedir } from "node:os";
5881
+ import { dirname, join } from "node:path";
5882
+ var MAX_PENDING_RESET_CHATS = 100;
5883
+ var MAX_PENDING_RESET_MESSAGES = 1e4;
5884
+ var MAX_RESET_RETRY_DELAY_MS = 6e4;
5885
+ var PendingRetryStateError = class extends Error {
5886
+ code;
5887
+ constructor(message, code) {
5888
+ super(message);
5889
+ this.name = "PendingRetryStateError";
5890
+ this.code = code;
5891
+ }
5892
+ };
5893
+ function retryStorePath(accountId, stateDir) {
5894
+ const root = stateDir ?? process.env.OPENCLAW_STATE_DIR?.trim() ?? join(homedir(), ".openclaw");
5895
+ const safeAccount = accountId.replace(/[^A-Za-z0-9._-]/g, "_");
5896
+ return join(root, "wechat", `monitor-retry-${safeAccount}.json`);
5897
+ }
5898
+ function quarantinePath(path) {
5899
+ return `${path}.corrupt`;
5900
+ }
5901
+ function blockerPath(path) {
5902
+ return `${path}.blocked`;
5903
+ }
5904
+ function syncPath(path) {
5905
+ const fd = openSync(path, "r");
5906
+ try {
5907
+ fsyncSync(fd);
5908
+ } finally {
5909
+ closeSync(fd);
5910
+ }
5911
+ }
5912
+ function syncParent(path) {
5913
+ syncPath(dirname(path));
5914
+ }
5915
+ function isStringArray(value) {
5916
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
5917
+ }
5918
+ function isFiniteNumber(value) {
5919
+ return typeof value === "number" && Number.isFinite(value);
5920
+ }
5921
+ function validateMessage(message) {
5922
+ if (message === null || typeof message !== "object") return false;
5923
+ const row = message;
5924
+ return Number.isSafeInteger(row.localId) && typeof row.chatId === "string" && Number.isSafeInteger(row.type) && typeof row.timestamp === "string" && (row.serverId === void 0 || Number.isSafeInteger(row.serverId)) && (row.content === void 0 || typeof row.content === "string") && (row.sender === void 0 || typeof row.sender === "string") && (row.isSelf === void 0 || typeof row.isSelf === "boolean") && (row.isMentioned === void 0 || typeof row.isMentioned === "boolean");
5925
+ }
5926
+ function validateChat(chat) {
5927
+ if (chat === null || typeof chat !== "object") return false;
5928
+ const row = chat;
5929
+ return typeof row.id === "string" && typeof row.name === "string" && Number.isSafeInteger(row.unreadCount) && typeof row.isGroup === "boolean" && (row.username === void 0 || typeof row.username === "string") && (row.lastMsgLocalId === void 0 || Number.isSafeInteger(row.lastMsgLocalId)) && (row.remark === void 0 || typeof row.remark === "string") && (row.members === void 0 || isStringArray(row.members));
5930
+ }
5931
+ function validateEntries(entries) {
5932
+ if (entries.length > MAX_PENDING_RESET_CHATS) {
5933
+ throw new PendingRetryStateError("WeChat pending retry chat capacity exceeded", "RETRY_CAPACITY");
5934
+ }
5935
+ let total = 0;
5936
+ for (const entry of entries) {
5937
+ if (!Array.isArray(entry) || typeof entry[0] !== "string") {
5938
+ throw new PendingRetryStateError("WeChat retry state entry is invalid", "RETRY_STATE_CORRUPT");
5939
+ }
5940
+ const pending = entry[1];
5941
+ if (pending?.readyForDispatch !== true || !validateChat(pending.chat) || !Array.isArray(pending.messages) || !pending.messages.every(validateMessage) || pending.generationReset !== void 0 && typeof pending.generationReset !== "boolean" || pending.retryCount !== void 0 && !Number.isSafeInteger(pending.retryCount) || pending.nextRetryAt !== void 0 && !isFiniteNumber(pending.nextRetryAt) || pending.createdAt !== void 0 && !isFiniteNumber(pending.createdAt)) {
5942
+ throw new PendingRetryStateError("WeChat pending retry payload is invalid", "RETRY_STATE_CORRUPT");
5943
+ }
5944
+ total += pending.messages.length;
5945
+ }
5946
+ if (total > MAX_PENDING_RESET_MESSAGES) {
5947
+ throw new PendingRetryStateError("WeChat pending retry message capacity exceeded", "RETRY_CAPACITY");
5948
+ }
5949
+ }
5950
+ function loadPendingResetRetries(accountId, stateDir) {
5951
+ const path = retryStorePath(accountId, stateDir);
5952
+ const blocked = blockerPath(path);
5953
+ if (existsSync(blocked)) {
5954
+ let quarantine = quarantinePath(path);
5955
+ try {
5956
+ const marker = JSON.parse(readFileSync(blocked, "utf8"));
5957
+ if (typeof marker.quarantine === "string") quarantine = marker.quarantine;
5958
+ } catch {
5959
+ }
5960
+ throw new PendingRetryStateError(
5961
+ `WeChat retry recovery is blocked by ${blocked}. Reconcile ${quarantine}, then explicitly remove the blocker to resume.`,
5962
+ "RETRY_STATE_BLOCKED"
5963
+ );
5964
+ }
5965
+ if (!existsSync(path)) return /* @__PURE__ */ new Map();
5966
+ try {
5967
+ const raw = JSON.parse(readFileSync(path, "utf8"));
5968
+ if (raw.version !== 1 || !Array.isArray(raw.entries)) {
5969
+ throw new PendingRetryStateError("WeChat retry state schema is invalid", "RETRY_STATE_CORRUPT");
5970
+ }
5971
+ const entries = raw.entries;
5972
+ validateEntries(entries);
5973
+ return new Map(entries);
5974
+ } catch (error) {
5975
+ const quarantine = quarantinePath(path);
5976
+ const blocked2 = blockerPath(path);
5977
+ try {
5978
+ rmSync(quarantine, { force: true });
5979
+ renameSync(path, quarantine);
5980
+ writeFileSync(blocked2, JSON.stringify({ version: 1, quarantine }), { mode: 384 });
5981
+ syncPath(blocked2);
5982
+ syncParent(path);
5983
+ } catch (quarantineError) {
5984
+ throw new PendingRetryStateError(
5985
+ `WeChat retry state is unreadable at ${path}; quarantine failed (${String(quarantineError)}). Stop the channel and move this file aside manually.`,
5986
+ "RETRY_STATE_CORRUPT"
5987
+ );
5988
+ }
5989
+ throw new PendingRetryStateError(
5990
+ `WeChat retry state was quarantined to ${quarantine} and blocked by ${blocked2}. Inspect/restore the quarantine, then explicitly remove the blocker to resume or discard after reconciliation. Cause: ${String(error)}`,
5991
+ "RETRY_STATE_CORRUPT"
5992
+ );
5993
+ }
5994
+ }
5995
+ function persistPendingResetRetries(accountId, pending, stateDir) {
5996
+ const path = retryStorePath(accountId, stateDir);
5997
+ const entries = [...pending.entries()].filter(([, value]) => value.readyForDispatch);
5998
+ validateEntries(entries);
5999
+ if (entries.length === 0) {
6000
+ if (!existsSync(path)) return;
6001
+ rmSync(path, { force: true });
6002
+ if (existsSync(dirname(path))) syncParent(path);
6003
+ return;
6004
+ }
6005
+ mkdirSync(dirname(path), { recursive: true });
6006
+ const temp = `${path}.tmp-${process.pid}`;
6007
+ writeFileSync(temp, JSON.stringify({ version: 1, entries }), { mode: 384 });
6008
+ syncPath(temp);
6009
+ renameSync(temp, path);
6010
+ syncParent(path);
6011
+ }
6012
+ function persist(state) {
6013
+ state.persist?.();
6014
+ }
6015
+ function mergeMessages(current, incoming) {
6016
+ const merged = /* @__PURE__ */ new Map();
6017
+ for (const message of [...current, ...incoming]) {
6018
+ merged.set(cursorMessageKey(message), message);
6019
+ }
6020
+ return [...merged.values()].sort((a, b) => a.localId - b.localId);
6021
+ }
6022
+ function assertStateCapacity(pending) {
6023
+ validateEntries([...pending.entries()]);
6024
+ }
6025
+ function trimPendingResetRetries(state) {
6026
+ assertStateCapacity(state.pendingMessageScans);
6027
+ persist(state);
6028
+ }
6029
+ function clearPendingResetRetry(state, chatId) {
6030
+ if (state.pendingMessageScans.delete(chatId)) persist(state);
6031
+ }
6032
+ function queueResetGenerationRetry(state, chatId, chat, messages, now = Date.now()) {
6033
+ const existing = state.pendingMessageScans.get(chatId);
6034
+ const merged = mergeMessages(existing?.messages ?? [], messages);
6035
+ const next = {
6036
+ chat,
6037
+ messages: merged,
6038
+ generationReset: true,
6039
+ readyForDispatch: true,
6040
+ retryCount: existing?.retryCount ?? 0,
6041
+ nextRetryAt: existing?.nextRetryAt ?? now,
6042
+ createdAt: existing?.createdAt ?? now
6043
+ };
6044
+ const projected = new Map(state.pendingMessageScans);
6045
+ projected.set(chatId, next);
6046
+ assertStateCapacity(projected);
6047
+ state.pendingMessageScans.delete(chatId);
6048
+ state.pendingMessageScans.set(chatId, next);
6049
+ persist(state);
6050
+ }
6051
+ function mergePendingResetMessages(state, chatId, chat, messages) {
6052
+ const pending = state.pendingMessageScans.get(chatId);
6053
+ if (!pending?.readyForDispatch) return messages;
6054
+ const merged = mergeMessages(pending.messages, messages);
6055
+ const projected = new Map(state.pendingMessageScans);
6056
+ projected.set(chatId, { ...pending, chat, messages: merged });
6057
+ assertStateCapacity(projected);
6058
+ state.pendingMessageScans.set(chatId, { ...pending, chat, messages: merged });
6059
+ persist(state);
6060
+ return merged;
6061
+ }
6062
+ function recordResetRetryFailure(state, chatId, now = Date.now()) {
6063
+ const pending = state.pendingMessageScans.get(chatId);
6064
+ if (!pending?.readyForDispatch) return;
6065
+ const retryCount = (pending.retryCount ?? 0) + 1;
6066
+ const delay = Math.min(MAX_RESET_RETRY_DELAY_MS, 1e3 * 2 ** Math.min(retryCount - 1, 6));
6067
+ state.pendingMessageScans.set(chatId, {
6068
+ ...pending,
6069
+ retryCount,
6070
+ nextRetryAt: now + delay
6071
+ });
6072
+ persist(state);
6073
+ }
6074
+ function monitorChatsToProcess(chats, state, now = Date.now()) {
6075
+ const result = /* @__PURE__ */ new Map();
6076
+ for (const pending of state.pendingMessageScans.values()) {
6077
+ const chatId = pending.chat.username ?? pending.chat.id;
6078
+ if ((pending.nextRetryAt ?? 0) <= now) result.set(chatId, pending.chat);
6079
+ }
6080
+ for (const chat of chats) {
6081
+ const chatId = chat.username ?? chat.id;
6082
+ if (chat.unreadCount > 0 || state.pendingMessageScans.has(chatId)) result.set(chatId, chat);
6083
+ }
6084
+ return result;
6085
+ }
6086
+ function commitResetDispatchPrefix(state, chatId, cursorMessage) {
6087
+ if (cursorMessage === void 0) return;
6088
+ const cursor = cursorMessage.localId;
6089
+ const pending = state.pendingMessageScans.get(chatId);
6090
+ if (!pending?.readyForDispatch) {
6091
+ state.lastSeenId.set(chatId, cursor);
6092
+ return;
6093
+ }
6094
+ const exactIndex = pending.messages.findIndex(
6095
+ (message) => cursorMessageKey(message) === cursorMessageKey(cursorMessage)
6096
+ );
6097
+ if (exactIndex < 0) {
6098
+ throw new PendingRetryStateError(
6099
+ `Successful reset cursor identity is absent from pending chat ${chatId}`,
6100
+ "RETRY_STATE_CORRUPT"
6101
+ );
6102
+ }
6103
+ const remaining = pending.messages.slice(exactIndex + 1);
6104
+ const projected = new Map(state.pendingMessageScans);
6105
+ if (remaining.length === 0) {
6106
+ projected.delete(chatId);
6107
+ } else {
6108
+ projected.set(chatId, { ...pending, messages: remaining, retryCount: 0, nextRetryAt: 0 });
6109
+ }
6110
+ assertStateCapacity(projected);
6111
+ state.lastSeenId.set(chatId, cursor);
6112
+ state.pendingMessageScans.clear();
6113
+ for (const [key, value] of projected) state.pendingMessageScans.set(key, value);
6114
+ persist(state);
6115
+ }
6116
+
5626
6117
  // src/access-control.ts
5627
6118
  import {
5628
6119
  buildChannelKeyCandidates,
@@ -5910,6 +6401,23 @@ async function startWeChatMonitor(opts) {
5910
6401
  const { account, abortSignal, setStatus, log } = opts;
5911
6402
  const client = new WeChatClient({ baseUrl: account.serverUrl, token: account.token });
5912
6403
  const lastSeenId = /* @__PURE__ */ new Map();
6404
+ const equalCursorUnreadHandled = /* @__PURE__ */ new Map();
6405
+ const startupBaselines = /* @__PURE__ */ new Map();
6406
+ const chatScanState = { initialScanComplete: false };
6407
+ const pendingMessageScans = loadPendingResetRetries(account.accountId);
6408
+ for (const [chatId, pending] of pendingMessageScans) {
6409
+ const cursor = pending.messages.reduce(
6410
+ (max, message) => Math.max(max, message.localId),
6411
+ 0
6412
+ );
6413
+ if (cursor > 0) lastSeenId.set(chatId, cursor);
6414
+ }
6415
+ const retryState = {
6416
+ pendingMessageScans,
6417
+ lastSeenId,
6418
+ persist: () => persistPendingResetRetries(account.accountId, pendingMessageScans)
6419
+ };
6420
+ trimPendingResetRetries(retryState);
5913
6421
  const groupHistory = /* @__PURE__ */ new Map();
5914
6422
  const GROUP_HISTORY_LIMIT = 50;
5915
6423
  let lastAuthCheck = 0;
@@ -5978,8 +6486,15 @@ async function startWeChatMonitor(opts) {
5978
6486
  }
5979
6487
  }
5980
6488
  let chats;
6489
+ let isInitialSnapshot = false;
5981
6490
  try {
5982
- chats = await client.listChats(50);
6491
+ if (!chatScanState.initialScanComplete) {
6492
+ chats = await listInitialMonitorChatSnapshot(client, chatScanState);
6493
+ isInitialSnapshot = true;
6494
+ } else {
6495
+ const page = await listNextMonitorChatPage(client, chatScanState);
6496
+ chats = page.chats;
6497
+ }
5983
6498
  } catch (err) {
5984
6499
  log?.error?.(
5985
6500
  `[wechat:${account.accountId}] Failed to list chats: ${err}`
@@ -5987,9 +6502,28 @@ async function startWeChatMonitor(opts) {
5987
6502
  await sleep(account.pollIntervalMs, abortSignal);
5988
6503
  continue;
5989
6504
  }
6505
+ if (isInitialSnapshot) {
6506
+ for (const chat of chats) {
6507
+ const chatId = chat.username ?? chat.id;
6508
+ if (!startupBaselines.has(chatId)) {
6509
+ const baseline = seedStartupBaselineFromChat(chat);
6510
+ if (baseline) startupBaselines.set(chatId, baseline);
6511
+ }
6512
+ }
6513
+ }
5990
6514
  const unreadChats = chats.filter(
5991
6515
  (c) => c.unreadCount > 0 && !isOfficialAccount(c.username ?? c.id)
5992
6516
  );
6517
+ for (const chat of unreadChats) {
6518
+ const chatId = chat.username ?? chat.id;
6519
+ if (chatScanState.initialScanComplete && !lastSeenId.has(chatId) && !startupBaselines.has(chatId)) {
6520
+ startupBaselines.set(chatId, { localId: 0 });
6521
+ }
6522
+ }
6523
+ const chatsToProcess = monitorChatsToProcess(chats, retryState);
6524
+ for (const chatId of chatsToProcess.keys()) {
6525
+ if (isOfficialAccount(chatId)) chatsToProcess.delete(chatId);
6526
+ }
5993
6527
  if (unreadChats.length > 0) {
5994
6528
  log?.info?.(
5995
6529
  `[wechat:${account.accountId}] ${unreadChats.length} chat(s) with unreads`
@@ -5997,13 +6531,17 @@ async function startWeChatMonitor(opts) {
5997
6531
  }
5998
6532
  let deferredThisTick = 0;
5999
6533
  let dispatchedThisTick = 0;
6000
- if (unreadChats.length > 0) {
6001
- for (const chat of unreadChats) {
6534
+ if (chatsToProcess.size > 0) {
6535
+ for (const chat of chatsToProcess.values()) {
6002
6536
  if (abortSignal.aborted) break;
6003
6537
  const result = await processUnreadChat(
6004
6538
  client,
6005
6539
  chat,
6006
6540
  lastSeenId,
6541
+ equalCursorUnreadHandled,
6542
+ startupBaselines,
6543
+ pendingMessageScans,
6544
+ retryState.persist,
6007
6545
  account,
6008
6546
  cfg,
6009
6547
  log,
@@ -6040,6 +6578,10 @@ async function startWeChatMonitor(opts) {
6040
6578
  client,
6041
6579
  chat,
6042
6580
  lastSeenId,
6581
+ equalCursorUnreadHandled,
6582
+ startupBaselines,
6583
+ pendingMessageScans,
6584
+ retryState.persist,
6043
6585
  account,
6044
6586
  cfg,
6045
6587
  log,
@@ -6473,7 +7015,7 @@ function bufferGroupHistory(groupHistory, chatId, pm, limit) {
6473
7015
  if (first) groupHistory.delete(first);
6474
7016
  }
6475
7017
  }
6476
- async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, skipOpen, groupHistory, groupHistoryLimit, catchup) {
7018
+ async function processUnreadChat(client, chat, lastSeenId, equalCursorUnreadHandled, startupBaselines, pendingMessageScans, persistRetryState, account, cfg, log, skipOpen, groupHistory, groupHistoryLimit, catchup) {
6477
7019
  const core = getWeChatRuntime();
6478
7020
  const liveAccount = resolveWeChatAccount(cfg, account.accountId) ?? account;
6479
7021
  const chatId = chat.username ?? chat.id;
@@ -6488,7 +7030,10 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6488
7030
  cfg,
6489
7031
  surface: "wechat"
6490
7032
  });
6491
- if (!skipOpen) {
7033
+ let opened = false;
7034
+ const openChatIfNeeded = async () => {
7035
+ if (skipOpen || opened) return;
7036
+ opened = true;
6492
7037
  log?.info?.(`[wechat:${liveAccount.accountId}] Opening chat ${chatId}...`);
6493
7038
  try {
6494
7039
  await client.openChat(chatId, true);
@@ -6498,14 +7043,54 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6498
7043
  `[wechat:${liveAccount.accountId}] Failed to open chat ${chatId}: ${err}`
6499
7044
  );
6500
7045
  }
6501
- }
7046
+ };
6502
7047
  const firstPoll = !lastSeenId.has(chatId);
6503
7048
  const prevLastSeen = lastSeenId.get(chatId) ?? 0;
6504
- const recoveryWindow = Math.max(liveAccount.catchUpMaxMessages, 20);
6505
- const fetchLimit = Math.min(Math.max(chat.unreadCount, 20), recoveryWindow);
7049
+ const startupBaseline = startupBaselines.get(chatId);
6506
7050
  let messages;
7051
+ let generationResetConfirmed = false;
6507
7052
  try {
6508
- messages = await client.listMessages(chatId, fetchLimit);
7053
+ const pending = pendingMessageScans.get(chatId);
7054
+ if (pending?.readyForDispatch) {
7055
+ const pendingTail = pending.messages.at(-1)?.localId ?? 0;
7056
+ const scan = await listMessagesForMonitorCursor(client, chatId, {
7057
+ chat,
7058
+ firstPoll: false,
7059
+ prevLastSeen: pendingTail,
7060
+ handledCursor: equalCursorUnreadHandled.get(chatId)
7061
+ });
7062
+ messages = mergePendingResetMessages(
7063
+ { pendingMessageScans, lastSeenId, persist: persistRetryState },
7064
+ chatId,
7065
+ chat,
7066
+ scan.messages
7067
+ );
7068
+ generationResetConfirmed = pending.generationReset === true;
7069
+ } else {
7070
+ const scan = await listMessagesForMonitorCursor(client, chatId, {
7071
+ chat,
7072
+ firstPoll,
7073
+ prevLastSeen,
7074
+ startupBaseline,
7075
+ handledCursor: equalCursorUnreadHandled.get(chatId),
7076
+ continuation: pending
7077
+ });
7078
+ if (!scan.complete) {
7079
+ pendingMessageScans.set(chatId, {
7080
+ chat,
7081
+ cursor: scan.nextCursor,
7082
+ messages: scan.messages,
7083
+ generationReset: scan.generationReset
7084
+ });
7085
+ log?.info?.(
7086
+ `[wechat:${liveAccount.accountId}] ${chatId}: deferred after ${scan.pagesScanned} message page(s); continuing paged scan next tick`
7087
+ );
7088
+ return "deferred";
7089
+ }
7090
+ messages = scan.messages;
7091
+ generationResetConfirmed = scan.generationReset === true;
7092
+ pendingMessageScans.delete(chatId);
7093
+ }
6509
7094
  } catch (err) {
6510
7095
  log?.error?.(
6511
7096
  `[wechat:${liveAccount.accountId}] Failed to list messages for ${chatId}: ${err}`
@@ -6516,33 +7101,72 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6516
7101
  `[wechat:${liveAccount.accountId}] ${chatId}: fetched ${messages.length} msgs, firstPoll=${firstPoll}, prevLastSeen=${prevLastSeen}, unreadCount=${chat.unreadCount}`
6517
7102
  );
6518
7103
  if (messages.length === 0) return "skipped";
6519
- let newMessages;
6520
- if (firstPoll) {
6521
- messages.sort((a, b) => a.localId - b.localId);
6522
- const unread = chat.unreadCount ?? 0;
6523
- if (unread > 0 && unread < messages.length) {
6524
- newMessages = messages.slice(-unread);
6525
- const seenMax = messages[messages.length - unread - 1].localId;
6526
- lastSeenId.set(chatId, seenMax);
6527
- } else if (unread >= messages.length) {
6528
- newMessages = messages;
6529
- } else {
6530
- const maxId2 = messages[messages.length - 1].localId;
6531
- lastSeenId.set(chatId, maxId2);
6532
- return "skipped";
7104
+ const enrichedBaseline = enrichStartupBaselineFromMessages(startupBaseline, messages);
7105
+ if (enrichedBaseline) {
7106
+ startupBaselines.set(chatId, enrichedBaseline);
7107
+ }
7108
+ const selected = selectCursorMessages(
7109
+ chatId,
7110
+ chat,
7111
+ messages,
7112
+ lastSeenId,
7113
+ equalCursorUnreadHandled,
7114
+ enrichedBaseline,
7115
+ generationResetConfirmed
7116
+ );
7117
+ let newMessages = selected.messages;
7118
+ if (selected.generationReset && !pendingMessageScans.get(chatId)?.readyForDispatch) {
7119
+ queueResetGenerationRetry(
7120
+ { pendingMessageScans, lastSeenId, persist: persistRetryState },
7121
+ chatId,
7122
+ chat,
7123
+ newMessages
7124
+ );
7125
+ }
7126
+ const advanceLastSeen = (handledMessages) => {
7127
+ const cursor = markCursorMessagesHandled(chatId, handledMessages, equalCursorUnreadHandled);
7128
+ if (cursor !== void 0) {
7129
+ lastSeenId.set(chatId, cursor);
7130
+ if (selected.generationReset) {
7131
+ startupBaselines.delete(chatId);
7132
+ }
7133
+ const baseline = startupBaselines.get(chatId);
7134
+ if (baseline && cursor >= baseline.localId) {
7135
+ startupBaselines.delete(chatId);
7136
+ }
7137
+ const exactCursorMessage = handledMessages.reduce(
7138
+ (latest, message) => latest === void 0 || message.localId > latest.localId ? message : message.localId === latest.localId ? message : latest,
7139
+ void 0
7140
+ );
7141
+ commitResetDispatchPrefix(
7142
+ { pendingMessageScans, lastSeenId, persist: persistRetryState },
7143
+ chatId,
7144
+ exactCursorMessage
7145
+ );
6533
7146
  }
6534
- } else {
6535
- newMessages = messages.filter((m) => m.localId > prevLastSeen);
6536
- if (newMessages.length === 0) {
6537
- return "skipped";
7147
+ };
7148
+ const seedLastSeen = selected.seedLastSeen;
7149
+ if (seedLastSeen !== void 0) {
7150
+ advanceLastSeen(messages.filter((message) => message.localId <= seedLastSeen));
7151
+ }
7152
+ if (newMessages.length === 0) {
7153
+ if (selected.generationReset) {
7154
+ clearPendingResetRetry(
7155
+ { pendingMessageScans, lastSeenId, persist: persistRetryState },
7156
+ chatId
7157
+ );
6538
7158
  }
6539
- newMessages.sort((a, b) => a.localId - b.localId);
7159
+ if (chat.unreadCount > 0) {
7160
+ await openChatIfNeeded();
7161
+ }
7162
+ return "skipped";
6540
7163
  }
7164
+ await openChatIfNeeded();
6541
7165
  const catchUpLimits = {
6542
7166
  maxMessages: liveAccount.catchUpMaxMessages,
6543
7167
  maxAgeMs: liveAccount.catchUpMaxAgeMs
6544
7168
  };
6545
- const isRecovery = firstPoll || skipOpen === true && isCatchUpBatch(newMessages, catchUpLimits);
7169
+ const isRecovery = firstPoll && startupBaseline === void 0 || skipOpen === true && isCatchUpBatch(newMessages, catchUpLimits);
6546
7170
  if (isRecovery) {
6547
7171
  const selection = selectCatchUpMessages(
6548
7172
  newMessages,
@@ -6551,7 +7175,7 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6551
7175
  );
6552
7176
  const observedCursor = recoveryCursor(selection);
6553
7177
  if (liveAccount.catchUpMode === "read-only") {
6554
- lastSeenId.set(chatId, observedCursor);
7178
+ advanceLastSeen(messages.filter((message) => message.localId <= observedCursor));
6555
7179
  log?.info?.(
6556
7180
  `[wechat:${liveAccount.accountId}] Recovery read-only: advanced ${chatId} to ${observedCursor} without dispatching ${newMessages.length} msg(s)`
6557
7181
  );
@@ -6559,7 +7183,7 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6559
7183
  }
6560
7184
  newMessages = selection.messages;
6561
7185
  if (newMessages.length === 0) {
6562
- lastSeenId.set(chatId, observedCursor);
7186
+ advanceLastSeen(messages.filter((message) => message.localId <= observedCursor));
6563
7187
  log?.info?.(
6564
7188
  `[wechat:${liveAccount.accountId}] Recovery skipped ${selection.skipped} stale msg(s) in ${chatId}`
6565
7189
  );
@@ -6597,7 +7221,7 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6597
7221
  }
6598
7222
  log?.info?.(`[wechat:${liveAccount.accountId}] Buffered ${processed.length} msg(s) for group history in ${chatId}`);
6599
7223
  const maxId2 = Math.max(...newMessages.map((m) => m.localId));
6600
- lastSeenId.set(chatId, maxId2);
7224
+ advanceLastSeen(newMessages.filter((message) => message.localId <= maxId2));
6601
7225
  return "skipped";
6602
7226
  }
6603
7227
  if (hasMention) {
@@ -6662,8 +7286,8 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6662
7286
  log?.info?.(
6663
7287
  `[wechat:${liveAccount.accountId}] Skipping stale group chatter in ${chatId}`
6664
7288
  );
6665
- const maxId3 = Math.max(...newMessages.map((m) => m.localId));
6666
- lastSeenId.set(chatId, maxId3);
7289
+ const maxId2 = Math.max(...newMessages.map((m) => m.localId));
7290
+ advanceLastSeen(newMessages.filter((message) => message.localId <= maxId2));
6667
7291
  return "skipped";
6668
7292
  }
6669
7293
  const fold = shouldFoldSegments(catchup?.isReconnect ?? false) && !hasControlCommandInWindow;
@@ -6672,6 +7296,7 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6672
7296
  `[wechat:${liveAccount.accountId}] ${chatId}: ${processed.length} dispatchable msg(s) in ${segments.length} segment(s) (${decision.reason})`
6673
7297
  );
6674
7298
  let allDispatched = true;
7299
+ const successfulSegmentLastLocalIds = [];
6675
7300
  for (let i = 0; i < segments.length; i++) {
6676
7301
  const remaining = segments.length - i - 1;
6677
7302
  const dispatched = await dispatchSegment(
@@ -6689,17 +7314,27 @@ async function processUnreadChat(client, chat, lastSeenId, account, cfg, log, sk
6689
7314
  );
6690
7315
  if (!dispatched) {
6691
7316
  allDispatched = false;
7317
+ break;
6692
7318
  }
7319
+ successfulSegmentLastLocalIds.push(
7320
+ Math.max(...segments[i].map((pm) => pm.msg.localId))
7321
+ );
6693
7322
  }
6694
7323
  if (clearBufferedHistory && allDispatched && groupHistory) {
6695
7324
  groupHistory.set(chatId, []);
6696
7325
  }
6697
- const maxId2 = Math.max(...newMessages.map((m) => m.localId));
6698
- lastSeenId.set(chatId, maxId2);
7326
+ const handledMessages = allDispatched ? newMessages : selectMessagesHandledAfterDispatch(newMessages, successfulSegmentLastLocalIds);
7327
+ advanceLastSeen(handledMessages);
7328
+ if (!allDispatched) {
7329
+ recordResetRetryFailure(
7330
+ { pendingMessageScans, lastSeenId, persist: persistRetryState },
7331
+ chatId
7332
+ );
7333
+ }
6699
7334
  return allDispatched ? "dispatched" : "skipped";
6700
7335
  }
6701
7336
  const maxId = Math.max(...newMessages.map((m) => m.localId));
6702
- lastSeenId.set(chatId, maxId);
7337
+ advanceLastSeen(newMessages.filter((message) => message.localId <= maxId));
6703
7338
  return "skipped";
6704
7339
  }
6705
7340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kyan-du/agent-wechat-openclaw",
3
- "version": "0.12.0-next.0",
3
+ "version": "0.12.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",
@@ -26,7 +26,7 @@
26
26
  "channel": {
27
27
  "id": "wechat",
28
28
  "label": "WeChat",
29
- "selectionLabel": "WeChat (\u5fae\u4fe1)",
29
+ "selectionLabel": "WeChat (微信)",
30
30
  "aliases": [
31
31
  "weixin"
32
32
  ],