|
@@ -40,6 +40,17 @@ import {
|
|
|
resolveApiBase,
|
|
resolveApiBase,
|
|
|
} from "./config.js";
|
|
} from "./config.js";
|
|
|
import type { Logger, RawWechatMessage, WechatAgentConfig } from "./types.js";
|
|
import type { Logger, RawWechatMessage, WechatAgentConfig } from "./types.js";
|
|
|
|
|
+import {
|
|
|
|
|
+ advanceCursor,
|
|
|
|
|
+ compareMsgId,
|
|
|
|
|
+ isAfterCursor,
|
|
|
|
|
+ loadCursor,
|
|
|
|
|
+ saveCursor,
|
|
|
|
|
+ toEpochMs,
|
|
|
|
|
+ type InboundCursor,
|
|
|
|
|
+} from "./cursor-store.js";
|
|
|
|
|
+import { join } from "node:path";
|
|
|
|
|
+import { withRetry } from "./retry.js";
|
|
|
|
|
|
|
|
export type MonitorOpts = {
|
|
export type MonitorOpts = {
|
|
|
cfg: OpenClawConfig;
|
|
cfg: OpenClawConfig;
|
|
@@ -89,6 +100,19 @@ export async function startMonitor(opts: MonitorOpts): Promise<MonitorResult> {
|
|
|
const batchWindowMs = Math.max(0, channelCfg.batchWindowMs ?? 3000);
|
|
const batchWindowMs = Math.max(0, channelCfg.batchWindowMs ?? 3000);
|
|
|
const replyCooldownMs = Math.max(0, (channelCfg.replyCooldownSecPerWxid ?? 5) * 1000);
|
|
const replyCooldownMs = Math.max(0, (channelCfg.replyCooldownSecPerWxid ?? 5) * 1000);
|
|
|
|
|
|
|
|
|
|
+ // Persistent inbound cursor: best-effort resume after restarts.
|
|
|
|
|
+ // We store it alongside OpenClaw's session store so admins can back it up
|
|
|
|
|
+ // together with other channel state.
|
|
|
|
|
+ const core = getRuntime();
|
|
|
|
|
+ const sessionCfg = (opts.cfg as Record<string, unknown>).session as
|
|
|
|
|
+ | { store?: string }
|
|
|
|
|
+ | undefined;
|
|
|
|
|
+ const cursorStoreDir = core.channel.session.resolveStorePath(sessionCfg?.store, {
|
|
|
|
|
+ agentId: CHANNEL_ID,
|
|
|
|
|
+ });
|
|
|
|
|
+ const cursorPath = join(cursorStoreDir, `${CHANNEL_ID}.inbound-cursor.json`);
|
|
|
|
|
+ let cursor: InboundCursor | null = await loadCursor(cursorPath);
|
|
|
|
|
+
|
|
|
// Resolve bot's own wxid so we can filter self-echoes.
|
|
// Resolve bot's own wxid so we can filter self-echoes.
|
|
|
let selfWxid: string | null = channelCfg.selfWxid?.trim() || null;
|
|
let selfWxid: string | null = channelCfg.selfWxid?.trim() || null;
|
|
|
if (!selfWxid) {
|
|
if (!selfWxid) {
|
|
@@ -182,7 +206,17 @@ export async function startMonitor(opts: MonitorOpts): Promise<MonitorResult> {
|
|
|
});
|
|
});
|
|
|
counters.pollsOk++;
|
|
counters.pollsOk++;
|
|
|
|
|
|
|
|
|
|
+ // Always process in stable order so agent context is consistent.
|
|
|
|
|
+ messages.sort((a, b) => {
|
|
|
|
|
+ const ta = toEpochMs(a.timestamp);
|
|
|
|
|
+ const tb = toEpochMs(b.timestamp);
|
|
|
|
|
+ if (ta !== tb) return ta - tb;
|
|
|
|
|
+ return compareMsgId(String(a.msgId), String(b.msgId));
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
for (const m of messages) {
|
|
for (const m of messages) {
|
|
|
|
|
+ // Cursor gate first: don't let old messages fill the dedupe window.
|
|
|
|
|
+ if (!isAfterCursor(m, cursor)) continue;
|
|
|
if (!markSeen(m.msgId)) continue;
|
|
if (!markSeen(m.msgId)) continue;
|
|
|
const ignoredReason = shouldIgnoreMessage(m, channelCfg, selfWxid);
|
|
const ignoredReason = shouldIgnoreMessage(m, channelCfg, selfWxid);
|
|
|
if (ignoredReason) {
|
|
if (ignoredReason) {
|
|
@@ -220,6 +254,17 @@ export async function startMonitor(opts: MonitorOpts): Promise<MonitorResult> {
|
|
|
|
|
|
|
|
// Batch + dispatch
|
|
// Batch + dispatch
|
|
|
cooldown.enqueue(m, resolveConversationId(m));
|
|
cooldown.enqueue(m, resolveConversationId(m));
|
|
|
|
|
+
|
|
|
|
|
+ // Advance cursor after enqueue. The actual agent dispatch is async,
|
|
|
|
|
+ // but enqueueing is the "accepted for processing" point for this
|
|
|
|
|
+ // channel. We still keep a bounded dedupe window to tolerate crashes
|
|
|
|
|
+ // between enqueue and outbound delivery.
|
|
|
|
|
+ cursor = advanceCursor(cursor, m);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Best-effort persist cursor once per tick.
|
|
|
|
|
+ if (cursor) {
|
|
|
|
|
+ await saveCursor(cursorPath, cursor);
|
|
|
}
|
|
}
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
counters.pollsFail++;
|
|
counters.pollsFail++;
|
|
@@ -415,11 +460,26 @@ async function dispatchBatchToAgent(params: DispatchParams): Promise<void> {
|
|
|
|
|
|
|
|
for (const chunk of parts) {
|
|
for (const chunk of parts) {
|
|
|
if (!chunk) continue;
|
|
if (!chunk) continue;
|
|
|
- const result = await client.sendText({
|
|
|
|
|
- toWxid: conversationId,
|
|
|
|
|
- content: chunk,
|
|
|
|
|
- ats: isGroup && channelCfg.groupReplyAtSender && senderWxid ? senderWxid : "",
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const result = await withRetry(
|
|
|
|
|
+ () =>
|
|
|
|
|
+ client.sendText({
|
|
|
|
|
+ toWxid: conversationId,
|
|
|
|
|
+ content: chunk,
|
|
|
|
|
+ ats:
|
|
|
|
|
+ isGroup && channelCfg.groupReplyAtSender && senderWxid ? senderWxid : "",
|
|
|
|
|
+ }),
|
|
|
|
|
+ {
|
|
|
|
|
+ tries: 4,
|
|
|
|
|
+ baseDelayMs: 500,
|
|
|
|
|
+ maxDelayMs: 4000,
|
|
|
|
|
+ isRetryable: (err) => {
|
|
|
|
|
+ const msg = err instanceof Error ? err.message : String(err);
|
|
|
|
|
+ if (/timeout|aborted|ECONN|ENOTFOUND|EAI_AGAIN/i.test(msg)) return true;
|
|
|
|
|
+ if (/HTTP 5\d\d/i.test(msg)) return true;
|
|
|
|
|
+ return false;
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
if (!result.ok) {
|
|
if (!result.ok) {
|
|
|
log.error(`send-text failed ret=${String(result.ret)} msg=${result.msg ?? ""}`);
|
|
log.error(`send-text failed ret=${String(result.ret)} msg=${result.msg ?? ""}`);
|
|
|
throw new Error(
|
|
throw new Error(
|