Skip to content

Commit 5709858

Browse files
author
OpenClaw Assistant
committed
Fix false WhatsApp stale-socket restarts on idle sessions
1 parent 13e256a commit 5709858

5 files changed

Lines changed: 120 additions & 12 deletions

File tree

extensions/whatsapp/src/auto-reply.web-auto-reply.connection-and-logging.e2e.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,78 @@ describe("web auto-reply connection", () => {
278278
}
279279
});
280280

281+
it("does not reconnect solely because the inbox stays quiet by default", async () => {
282+
vi.useFakeTimers();
283+
try {
284+
const sleep = vi.fn(async () => {});
285+
const closeResolvers: Array<(reason: unknown) => void> = [];
286+
let capturedOnMessage:
287+
| ((msg: import("./inbound.js").WebInboundMessage) => Promise<void>)
288+
| undefined;
289+
const listenerFactory = vi.fn(
290+
async (opts: {
291+
onMessage: (msg: import("./inbound.js").WebInboundMessage) => Promise<void>;
292+
}) => {
293+
capturedOnMessage = opts.onMessage;
294+
let resolveClose: (reason: unknown) => void = () => {};
295+
const onClose = new Promise<unknown>((res) => {
296+
resolveClose = res;
297+
closeResolvers.push(res);
298+
});
299+
return {
300+
close: vi.fn(),
301+
onClose,
302+
signalClose: (reason?: unknown) => resolveClose(reason),
303+
};
304+
},
305+
);
306+
const { controller, run } = startMonitorWebChannel({
307+
monitorWebChannelFn: monitorWebChannel as never,
308+
listenerFactory,
309+
sleep,
310+
heartbeatSeconds: 60,
311+
watchdogCheckMs: 5,
312+
});
313+
314+
await Promise.resolve();
315+
expect(listenerFactory).toHaveBeenCalledTimes(1);
316+
await vi.waitFor(
317+
() => {
318+
expect(capturedOnMessage).toBeTypeOf("function");
319+
},
320+
{ timeout: 250, interval: 2 },
321+
);
322+
323+
const reply = vi.fn().mockResolvedValue(undefined);
324+
const sendComposing = vi.fn();
325+
const sendMedia = vi.fn();
326+
327+
await capturedOnMessage?.(
328+
makeInboundMessage({
329+
body: "hi",
330+
from: "+1",
331+
to: "+2",
332+
id: "m1",
333+
sendComposing,
334+
reply,
335+
sendMedia,
336+
}),
337+
);
338+
339+
await vi.advanceTimersByTimeAsync(5 * 60 * 1_000);
340+
await Promise.resolve();
341+
expect(listenerFactory).toHaveBeenCalledTimes(1);
342+
expect(sleep).not.toHaveBeenCalled();
343+
344+
controller.abort();
345+
closeResolvers[0]?.({ status: 499, isLoggedOut: false });
346+
await Promise.resolve();
347+
await run;
348+
} finally {
349+
vi.useRealTimers();
350+
}
351+
});
352+
281353
it("processes inbound messages without batching and preserves timestamps", async () => {
282354
await withEnvAsync({ TZ: "Europe/Vienna" }, async () => {
283355
const originalMax = process.getMaxListeners();

extensions/whatsapp/src/auto-reply/monitor.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,10 @@ export async function monitorWebChannel(
156156
let _lastInboundMsg: WebInboundMsg | null = null;
157157
let unregisterUnhandled: (() => void) | null = null;
158158

159-
// Watchdog to detect stuck message processing (e.g., event emitter died).
160-
// Tuning overrides are test-oriented; production defaults remain unchanged.
161-
const MESSAGE_TIMEOUT_MS = tuning.messageTimeoutMs ?? 30 * 60 * 1000; // 30m default
159+
// The WhatsApp inbox can legitimately stay quiet for long stretches, so the
160+
// absence of inbound messages is not reliable evidence that the socket died.
161+
// Keep the watchdog opt-in via tuning for tests and targeted diagnostics.
162+
const MESSAGE_TIMEOUT_MS = tuning.messageTimeoutMs ?? Number.POSITIVE_INFINITY;
162163
const WATCHDOG_CHECK_MS = tuning.watchdogCheckMs ?? 60 * 1000; // 1m default
163164

164165
const backgroundTasks = new Set<Promise<unknown>>();
@@ -286,11 +287,7 @@ export async function monitorWebChannel(
286287
: {}),
287288
};
288289

289-
if (minutesSinceLastMessage && minutesSinceLastMessage > 30) {
290-
heartbeatLogger.warn(logData, "⚠️ web gateway heartbeat - no messages in 30+ minutes");
291-
} else {
292-
heartbeatLogger.info(logData, "web gateway heartbeat");
293-
}
290+
heartbeatLogger.info(logData, "web gateway heartbeat");
294291
}, heartbeatSeconds * 1000);
295292

296293
watchdogTimer = setInterval(() => {

src/gateway/channel-health-monitor.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,23 @@ describe("channel-health-monitor", () => {
557557
await expectNoRestart(manager);
558558
});
559559

560+
it("skips WhatsApp idle channels even when lastEventAt is old", async () => {
561+
const now = Date.now();
562+
const manager = createSnapshotManager({
563+
whatsapp: {
564+
default: {
565+
running: true,
566+
connected: true,
567+
enabled: true,
568+
configured: true,
569+
lastStartAt: now - STALE_THRESHOLD - 60_000,
570+
lastEventAt: now - STALE_THRESHOLD - 30_000,
571+
},
572+
},
573+
});
574+
await expectNoRestart(manager);
575+
});
576+
560577
it("respects custom staleEventThresholdMs", async () => {
561578
const customThreshold = 10 * 60_000;
562579
const now = Date.now();

src/gateway/channel-health-policy.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,26 @@ describe("evaluateChannelHealth", () => {
156156
expect(evaluation).toEqual({ healthy: true, reason: "healthy" });
157157
});
158158

159+
it("skips stale-socket detection for WhatsApp idle periods", () => {
160+
const evaluation = evaluateChannelHealth(
161+
{
162+
running: true,
163+
connected: true,
164+
enabled: true,
165+
configured: true,
166+
lastStartAt: 0,
167+
lastEventAt: 0,
168+
},
169+
{
170+
channelId: "whatsapp",
171+
now: 100_000,
172+
channelConnectGraceMs: 10_000,
173+
staleEventThresholdMs: 30_000,
174+
},
175+
);
176+
expect(evaluation).toEqual({ healthy: true, reason: "healthy" });
177+
});
178+
159179
it("skips stale-socket detection for channels in webhook mode", () => {
160180
const evaluation = evaluateDiscordHealth({
161181
running: true,

src/gateway/channel-health-policy.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,12 +106,14 @@ export function evaluateChannelHealth(
106106
if (snapshot.connected === false) {
107107
return { healthy: false, reason: "disconnected" };
108108
}
109-
// Skip stale-socket check for Telegram (long-polling mode) and any channel
110-
// explicitly operating in webhook mode. In these cases, there is no persistent
111-
// outgoing socket that can go half-dead, so the lack of incoming events
112-
// does not necessarily indicate a connection failure.
109+
// Skip stale-socket check for Telegram (long-polling mode), WhatsApp Web
110+
// (its lastEventAt currently tracks inbound message flow rather than socket
111+
// liveness), and any channel explicitly operating in webhook mode. In these
112+
// cases, the lack of incoming events does not necessarily indicate a broken
113+
// connection.
113114
if (
114115
policy.channelId !== "telegram" &&
116+
policy.channelId !== "whatsapp" &&
115117
snapshot.mode !== "webhook" &&
116118
snapshot.connected === true &&
117119
snapshot.lastEventAt != null

0 commit comments

Comments
 (0)