Skip to content

Commit 21a92ea

Browse files
authored
fix(whatsapp): recover group inbound after reconnect churn
Repair WhatsApp group inbound recovery after repeated reconnect churn while keeping the fallback scoped to reconnect metadata. Canonical issue: #66920. Related evidence: #7433, #63855, #70856. Thanks to legonhilltech-jpg, octopuslabs-fl, Kanorin-chan, and stuswan for the reports and reproduction details.
1 parent 4f73baf commit 21a92ea

9 files changed

Lines changed: 269 additions & 29 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Docs: https://docs.openclaw.ai
132132
- Local model prompt caching: keep stable Project Context above volatile channel/session prompt guidance and stop embedding current channel names in the message tool description, so Ollama, MLX, llama.cpp, and other prefix-cache backends avoid avoidable full prompt reprocessing across channel turns. Fixes #40256; supersedes #40296. Thanks @rhclaw and @sriram369.
133133
- Gateway/OpenAI-compatible API: guard provider policy lookup against runtime providers with non-array `models` values, so `/v1/chat/completions` no longer fails with `provider?.models?.some is not a function`. Fixes #66744; carries forward #66761. Thanks @MightyMoud, @MukundaKatta.
134134
- WhatsApp/Web: pass explicit Baileys socket timings into every WhatsApp Web socket and expose `web.whatsapp.*` keepalive, connect, and query timeout settings so unstable networks can avoid repeated 408 disconnect and opening-handshake timeout loops. Fixes #56365. (#73580) Thanks @velvet-shark.
135+
- WhatsApp/Web: recover recently active listeners when a post-408 reconnect keeps receiving transport frames but stops delivering app messages, while keeping group metadata fallback off Baileys sends. Fixes #63855 and #66920; refs #7433, #67986, #70856, #60007, and #72621. Thanks @legonhilltech-jpg, @octopuslabs-fl, @Kanorin-chan, and @stuswan.
135136
- Channels/Telegram: persist native command metadata on target sessions so topic, helper, and ACP-bound slash commands keep their session metadata attached to the routed conversation. (#57548) Thanks @GaosCode.
136137
- Channels/native commands: keep validated native slash command replies visible in group chats while preserving explicit owner allowlists for command authorization. (#73672) Thanks @obviyus.
137138
- Pairing/doctor: bootstrap `commands.ownerAllowFrom` from the first approved DM pairing when no command owner exists, and have doctor explain missing owners so privileged slash commands are not accidentally unusable after onboarding. Thanks @pashpashpash.

docs/channels/whatsapp.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ OpenClaw recommends running WhatsApp on a separate number when possible. (The ch
151151
## Runtime model
152152

153153
- Gateway owns the WhatsApp socket and reconnect loop.
154-
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window.
154+
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window; after a transient reconnect for a recently active session, that application-silence check uses the normal message timeout for the first recovery window.
155155
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query timeouts.
156156
- Outbound sends require an active WhatsApp listener for the target account.
157157
- Status and broadcast chats are ignored (`@status`, `@broadcast`).

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,51 @@ describe("web auto-reply connection", () => {
513513
}
514514
});
515515

516+
it("recovers a post-408 listener when transport frames continue but app delivery stays silent", async () => {
517+
vi.useFakeTimers();
518+
try {
519+
const { scripted, controller, run } = await startWatchdogScenario({
520+
monitorWebChannel,
521+
});
522+
523+
scripted.resolveClose(0, {
524+
status: 408,
525+
isLoggedOut: false,
526+
error: "status=408 Request Time-out",
527+
});
528+
await vi.waitFor(
529+
() => {
530+
expect(scripted.getListenerCount()).toBe(2);
531+
},
532+
{ timeout: 250, interval: 2 },
533+
);
534+
535+
const reconnectedSocket = getLastWebAutoReplySessionSocket();
536+
for (let elapsedMs = 0; elapsedMs < 45; elapsedMs += 5) {
537+
reconnectedSocket.ws.emit("frame");
538+
await vi.advanceTimersByTimeAsync(5);
539+
}
540+
541+
await vi.waitFor(
542+
() => {
543+
expect(scripted.getListenerCount()).toBeGreaterThanOrEqual(3);
544+
},
545+
{ timeout: 250, interval: 2 },
546+
);
547+
548+
controller.abort();
549+
scripted.resolveClose(scripted.getListenerCount() - 1, {
550+
status: 499,
551+
isLoggedOut: false,
552+
error: "aborted",
553+
});
554+
await Promise.resolve();
555+
await run;
556+
} finally {
557+
vi.useRealTimers();
558+
}
559+
});
560+
516561
it("gives a reconnected listener a fresh watchdog window", async () => {
517562
vi.useFakeTimers();
518563
try {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
WhatsAppConnectionController,
2121
type ManagedWhatsAppListener,
2222
} from "../connection-controller.js";
23-
import { attachWebInboxToSocket } from "../inbound/monitor.js";
23+
import { attachWebInboxToSocket, type WhatsAppGroupMetadataCache } from "../inbound/monitor.js";
2424
import {
2525
newConnectionId,
2626
resolveHeartbeatSeconds,
@@ -202,6 +202,7 @@ export async function monitorWebChannel(
202202
}>
203203
>();
204204
const groupMemberNames = new Map<string, Map<string, string>>();
205+
const groupMetadataCache: WhatsAppGroupMetadataCache = new Map();
205206
const echoTracker = createEchoTracker({ maxItems: 100, logVerbose });
206207

207208
const sleep =
@@ -305,6 +306,7 @@ export async function monitorWebChannel(
305306
shouldRetryDisconnect: () => !sigintStop && controller.shouldRetryDisconnect(),
306307
disconnectRetryPolicy: reconnectPolicy,
307308
disconnectRetryAbortSignal: controller.getDisconnectRetryAbortSignal(),
309+
groupMetadataCache,
308310
onMessage: async (msg: WebInboundMsg) => {
309311
const inboundAt = Date.now();
310312
controller.noteInbound(inboundAt);

extensions/whatsapp/src/connection-controller.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export type WhatsAppLiveConnection = {
4545
handledMessages: number;
4646
unregisterUnhandled: (() => void) | null;
4747
unregisterTransportActivity: (() => void) | null;
48+
openedAfterRecentInbound: boolean;
4849
backgroundTasks: Set<Promise<unknown>>;
4950
closePromise: Promise<WebListenerCloseReason>;
5051
resolveClose: (reason: WebListenerCloseReason) => void;
@@ -97,6 +98,7 @@ function createLiveConnection(params: {
9798
connectionId: string;
9899
sock: WASocket;
99100
listener: ManagedWhatsAppListener;
101+
openedAfterRecentInbound: boolean;
100102
}): WhatsAppLiveConnection {
101103
let closeResolved = false;
102104
let resolveClosePromise = (_reason: WebListenerCloseReason) => {};
@@ -122,6 +124,7 @@ function createLiveConnection(params: {
122124
handledMessages: 0,
123125
unregisterUnhandled: null,
124126
unregisterTransportActivity: null,
127+
openedAfterRecentInbound: params.openedAfterRecentInbound,
125128
backgroundTasks: new Set<Promise<unknown>>(),
126129
closePromise,
127130
resolveClose: resolveClosePromise,
@@ -259,6 +262,7 @@ export class WhatsAppConnectionController {
259262

260263
private current: WhatsAppLiveConnection | null = null;
261264
private reconnectAttempts = 0;
265+
private lastHandledInboundAt: number | null = null;
262266

263267
constructor(params: {
264268
accountId: string;
@@ -334,6 +338,8 @@ export class WhatsAppConnectionController {
334338
this.current.handledMessages += 1;
335339
this.current.lastInboundAt = timestamp;
336340
this.current.lastTransportActivityAt = timestamp;
341+
this.current.openedAfterRecentInbound = false;
342+
this.lastHandledInboundAt = timestamp;
337343
}
338344

339345
noteTransportActivity(timestamp = Date.now()): void {
@@ -397,6 +403,7 @@ export class WhatsAppConnectionController {
397403
connectionId: params.connectionId,
398404
sock,
399405
listener: placeholderListener,
406+
openedAfterRecentInbound: this.isOpeningAfterRecentInbound(),
400407
});
401408
const listener = await params.createListener({ sock, connection });
402409
connection.listener = listener;
@@ -602,10 +609,10 @@ export class WhatsAppConnectionController {
602609
const transportStaleForMs = now - connection.lastTransportActivityAt;
603610
const appBaselineAt = connection.lastInboundAt ?? connection.startedAt;
604611
const appSilentForMs = now - appBaselineAt;
605-
if (
606-
transportStaleForMs <= this.transportTimeoutMs &&
607-
appSilentForMs <= this.appSilenceTimeoutMs
608-
) {
612+
const appSilenceTimeoutMs = connection.openedAfterRecentInbound
613+
? this.messageTimeoutMs
614+
: this.appSilenceTimeoutMs;
615+
if (transportStaleForMs <= this.transportTimeoutMs && appSilentForMs <= appSilenceTimeoutMs) {
609616
return;
610617
}
611618
const snapshot = this.getCurrentSnapshot(connection);
@@ -639,6 +646,13 @@ export class WhatsAppConnectionController {
639646
};
640647
}
641648

649+
private isOpeningAfterRecentInbound(): boolean {
650+
if (this.reconnectAttempts <= 0 || this.lastHandledInboundAt === null) {
651+
return false;
652+
}
653+
return Date.now() - this.lastHandledInboundAt <= this.appSilenceTimeoutMs;
654+
}
655+
642656
private stopDisconnectRetries(): void {
643657
if (!this.disconnectRetryController.signal.aborted) {
644658
this.disconnectRetryController.abort();

extensions/whatsapp/src/inbound/monitor.ts

Lines changed: 102 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
AnyMessageContent,
33
MiscMessageGenerationOptions,
44
proto,
5+
GroupMetadata,
56
WAMessage,
67
WASocket,
78
} from "@whiskeysockets/baileys";
@@ -45,6 +46,53 @@ import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
4546

4647
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
4748
const RECONNECT_IN_PROGRESS_ERROR = "no active socket - reconnection in progress";
49+
const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
50+
export const WHATSAPP_GROUP_METADATA_CACHE_MAX_ENTRIES = 500;
51+
52+
export type WhatsAppGroupMetadataCacheEntry = {
53+
subject?: string;
54+
expires: number;
55+
};
56+
export type WhatsAppGroupMetadataCache = Map<string, WhatsAppGroupMetadataCacheEntry>;
57+
type LocalGroupMetadataCacheEntry = WhatsAppGroupMetadataCacheEntry & {
58+
participants?: string[];
59+
};
60+
61+
function rememberGroupMetadataCacheEntry<T extends WhatsAppGroupMetadataCacheEntry>(
62+
cache: Map<string, T>,
63+
jid: string,
64+
entry: T,
65+
): void {
66+
if (cache.has(jid)) {
67+
cache.delete(jid);
68+
}
69+
cache.set(jid, entry);
70+
71+
while (cache.size > WHATSAPP_GROUP_METADATA_CACHE_MAX_ENTRIES) {
72+
const oldest = cache.keys().next();
73+
if (oldest.done) {
74+
break;
75+
}
76+
cache.delete(oldest.value);
77+
}
78+
}
79+
80+
function readGroupMetadataCacheEntry<T extends WhatsAppGroupMetadataCacheEntry>(
81+
cache: Map<string, T>,
82+
jid: string,
83+
): T | null {
84+
const entry = cache.get(jid);
85+
if (!entry) {
86+
return null;
87+
}
88+
if (entry.expires <= Date.now()) {
89+
cache.delete(jid);
90+
return null;
91+
}
92+
cache.delete(jid);
93+
cache.set(jid, entry);
94+
return entry;
95+
}
4896

4997
function logWhatsAppVerbose(enabled: boolean | undefined, message: string) {
5098
if (!enabled) {
@@ -98,6 +146,8 @@ export type MonitorWebInboxOptions = {
98146
};
99147
/** Abort in-flight reconnect waits when shutdown becomes terminal. */
100148
disconnectRetryAbortSignal?: AbortSignal;
149+
/** Shared group metadata cache used only for inbound metadata fallback after fetch failures. */
150+
groupMetadataCache?: WhatsAppGroupMetadataCache;
101151
};
102152

103153
export async function attachWebInboxToSocket(
@@ -234,11 +284,8 @@ export async function attachWebInboxToSocket(
234284
inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`);
235285
},
236286
});
237-
const groupMetaCache = new Map<
238-
string,
239-
{ subject?: string; participants?: string[]; expires: number }
240-
>();
241-
const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
287+
const groupMetadataCache = options.groupMetadataCache ?? new Map();
288+
const groupMetaCache = new Map<string, LocalGroupMetadataCacheEntry>();
242289
const lidLookup = sock.signalRepository?.lidMapping;
243290

244291
const resolveInboundJid = async (jid: string | null | undefined): Promise<string | null> =>
@@ -306,30 +353,54 @@ export async function attachWebInboxToSocket(
306353
}
307354
};
308355

356+
const summarizeGroupMeta = async (meta: GroupMetadata) => {
357+
const participants =
358+
(
359+
await Promise.all(
360+
meta.participants?.map(async (p) => {
361+
const mapped = await resolveInboundJid(p.id);
362+
return mapped ?? p.id;
363+
}) ?? [],
364+
)
365+
).filter(Boolean) ?? [];
366+
return {
367+
subject: meta.subject,
368+
participants,
369+
expires: Date.now() + GROUP_META_TTL_MS,
370+
};
371+
};
372+
373+
const summarizeGroupMetaForReconnectCache = (
374+
meta: GroupMetadata,
375+
): WhatsAppGroupMetadataCacheEntry => ({
376+
subject: meta.subject,
377+
expires: Date.now() + GROUP_META_TTL_MS,
378+
});
379+
309380
const getGroupMeta = async (jid: string) => {
310-
const cached = groupMetaCache.get(jid);
311-
if (cached && cached.expires > Date.now()) {
381+
const cached = readGroupMetadataCacheEntry(groupMetaCache, jid);
382+
if (cached) {
312383
return cached;
313384
}
314385
try {
315386
const meta = await sock.groupMetadata(jid);
316-
const participants =
317-
(
318-
await Promise.all(
319-
meta.participants?.map(async (p) => {
320-
const mapped = await resolveInboundJid(p.id);
321-
return mapped ?? p.id;
322-
}) ?? [],
323-
)
324-
).filter(Boolean) ?? [];
325-
const entry = {
326-
subject: meta.subject,
327-
participants,
328-
expires: Date.now() + GROUP_META_TTL_MS,
329-
};
330-
groupMetaCache.set(jid, entry);
387+
const entry = await summarizeGroupMeta(meta);
388+
rememberGroupMetadataCacheEntry(groupMetadataCache, jid, {
389+
subject: entry.subject,
390+
expires: entry.expires,
391+
});
392+
rememberGroupMetadataCacheEntry(groupMetaCache, jid, entry);
331393
return entry;
332394
} catch (err) {
395+
const hydrated = readGroupMetadataCacheEntry(groupMetadataCache, jid);
396+
if (hydrated) {
397+
rememberGroupMetadataCacheEntry(groupMetaCache, jid, hydrated);
398+
logWhatsAppVerbose(
399+
options.verbose,
400+
`Using cached group metadata for ${jid} after fetch failure: ${String(err)}`,
401+
);
402+
return hydrated;
403+
}
333404
logWhatsAppVerbose(
334405
options.verbose,
335406
`Failed to fetch group metadata for ${jid}: ${String(err)}`,
@@ -733,6 +804,15 @@ export async function attachWebInboxToSocket(
733804
void (async () => {
734805
try {
735806
const groups = await sock.groupFetchAllParticipating();
807+
for (const [jid, meta] of Object.entries(groups ?? {})) {
808+
if (meta) {
809+
rememberGroupMetadataCacheEntry(
810+
groupMetadataCache,
811+
jid,
812+
summarizeGroupMetaForReconnectCache(meta),
813+
);
814+
}
815+
}
736816
logWhatsAppVerbose(
737817
options.verbose,
738818
`Hydrated ${Object.keys(groups ?? {}).length} participating groups on connect`,

0 commit comments

Comments
 (0)