Skip to content

Commit 2b40dc3

Browse files
committed
fix(whatsapp): bound group metadata fallback cache
1 parent 3446188 commit 2b40dc3

2 files changed

Lines changed: 86 additions & 16 deletions

File tree

extensions/whatsapp/src/inbound/monitor.ts

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,51 @@ import type { WebInboundMessage, WebListenerCloseReason } from "./types.js";
4646

4747
const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401;
4848
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;
4951

50-
export type WhatsAppGroupMetadataCache = Map<string, GroupMetadata>;
52+
export type WhatsAppGroupMetadataCacheEntry = {
53+
subject?: string;
54+
participants?: string[];
55+
expires: number;
56+
};
57+
export type WhatsAppGroupMetadataCache = Map<string, WhatsAppGroupMetadataCacheEntry>;
58+
59+
function rememberGroupMetadataCacheEntry(
60+
cache: WhatsAppGroupMetadataCache,
61+
jid: string,
62+
entry: WhatsAppGroupMetadataCacheEntry,
63+
): void {
64+
if (cache.has(jid)) {
65+
cache.delete(jid);
66+
}
67+
cache.set(jid, entry);
68+
69+
while (cache.size > WHATSAPP_GROUP_METADATA_CACHE_MAX_ENTRIES) {
70+
const oldest = cache.keys().next();
71+
if (oldest.done) {
72+
break;
73+
}
74+
cache.delete(oldest.value);
75+
}
76+
}
77+
78+
function readGroupMetadataCacheEntry(
79+
cache: WhatsAppGroupMetadataCache,
80+
jid: string,
81+
): WhatsAppGroupMetadataCacheEntry | null {
82+
const entry = cache.get(jid);
83+
if (!entry) {
84+
return null;
85+
}
86+
if (entry.expires <= Date.now()) {
87+
cache.delete(jid);
88+
return null;
89+
}
90+
cache.delete(jid);
91+
cache.set(jid, entry);
92+
return entry;
93+
}
5194

5295
function logWhatsAppVerbose(enabled: boolean | undefined, message: string) {
5396
if (!enabled) {
@@ -239,12 +282,8 @@ export async function attachWebInboxToSocket(
239282
inboundConsoleLog.error(`Failed handling inbound web message: ${String(err)}`);
240283
},
241284
});
242-
const groupMetadataCache = options.groupMetadataCache ?? new Map<string, GroupMetadata>();
243-
const groupMetaCache = new Map<
244-
string,
245-
{ subject?: string; participants?: string[]; expires: number }
246-
>();
247-
const GROUP_META_TTL_MS = 5 * 60 * 1000; // 5 minutes
285+
const groupMetadataCache = options.groupMetadataCache ?? new Map();
286+
const groupMetaCache: WhatsAppGroupMetadataCache = new Map();
248287
const lidLookup = sock.signalRepository?.lidMapping;
249288

250289
const resolveInboundJid = async (jid: string | null | undefined): Promise<string | null> =>
@@ -330,26 +369,25 @@ export async function attachWebInboxToSocket(
330369
};
331370

332371
const getGroupMeta = async (jid: string) => {
333-
const cached = groupMetaCache.get(jid);
334-
if (cached && cached.expires > Date.now()) {
372+
const cached = readGroupMetadataCacheEntry(groupMetaCache, jid);
373+
if (cached) {
335374
return cached;
336375
}
337376
try {
338377
const meta = await sock.groupMetadata(jid);
339-
groupMetadataCache.set(jid, meta);
340378
const entry = await summarizeGroupMeta(meta);
341-
groupMetaCache.set(jid, entry);
379+
rememberGroupMetadataCacheEntry(groupMetadataCache, jid, entry);
380+
rememberGroupMetadataCacheEntry(groupMetaCache, jid, entry);
342381
return entry;
343382
} catch (err) {
344-
const hydrated = groupMetadataCache.get(jid);
383+
const hydrated = readGroupMetadataCacheEntry(groupMetadataCache, jid);
345384
if (hydrated) {
346-
const entry = await summarizeGroupMeta(hydrated);
347-
groupMetaCache.set(jid, entry);
385+
rememberGroupMetadataCacheEntry(groupMetaCache, jid, hydrated);
348386
logWhatsAppVerbose(
349387
options.verbose,
350388
`Using cached group metadata for ${jid} after fetch failure: ${String(err)}`,
351389
);
352-
return entry;
390+
return hydrated;
353391
}
354392
logWhatsAppVerbose(
355393
options.verbose,
@@ -756,7 +794,7 @@ export async function attachWebInboxToSocket(
756794
const groups = await sock.groupFetchAllParticipating();
757795
for (const [jid, meta] of Object.entries(groups ?? {})) {
758796
if (meta) {
759-
groupMetadataCache.set(jid, meta);
797+
rememberGroupMetadataCacheEntry(groupMetadataCache, jid, await summarizeGroupMeta(meta));
760798
}
761799
}
762800
logWhatsAppVerbose(

extensions/whatsapp/src/monitor-inbox.streams-inbound-messages.test-support.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "node:path";
33
import "./monitor-inbox.test-harness.js";
44
import { beforeEach, describe, expect, it, vi } from "vitest";
55
import { WhatsAppRetryableInboundError } from "./inbound/dedupe.js";
6+
import { WHATSAPP_GROUP_METADATA_CACHE_MAX_ENTRIES } from "./inbound/monitor.js";
67
import {
78
type InboxMonitorOptions,
89
InboxOnMessage,
@@ -268,6 +269,37 @@ describe("web monitor inbox", () => {
268269
await second.listener.close();
269270
});
270271

272+
it("bounds cached group metadata kept across reconnects", async () => {
273+
const groupMetadataCache: NonNullable<InboxMonitorOptions["groupMetadataCache"]> = new Map();
274+
const groups = Object.fromEntries(
275+
Array.from({ length: WHATSAPP_GROUP_METADATA_CACHE_MAX_ENTRIES + 2 }, (_, index) => [
276+
`${index}@g.us`,
277+
{
278+
id: `${index}@g.us`,
279+
subject: `Group ${index}`,
280+
owner: undefined,
281+
participants: [],
282+
},
283+
]),
284+
);
285+
const sock = getSock();
286+
sock.groupFetchAllParticipating.mockResolvedValueOnce(groups);
287+
288+
const { listener } = await startInboxMonitor(vi.fn(async () => {}) as InboxOnMessage, {
289+
groupMetadataCache,
290+
});
291+
292+
await vi.waitFor(() => {
293+
expect(groupMetadataCache.size).toBe(WHATSAPP_GROUP_METADATA_CACHE_MAX_ENTRIES);
294+
});
295+
expect(groupMetadataCache.has("[email protected]")).toBe(false);
296+
expect(groupMetadataCache.has(`${WHATSAPP_GROUP_METADATA_CACHE_MAX_ENTRIES + 1}@g.us`)).toBe(
297+
true,
298+
);
299+
300+
await listener.close();
301+
});
302+
271303
it("does not block inbound listeners while group hydration is pending", async () => {
272304
let resolveHydration!: () => void;
273305
const sock = getSock();

0 commit comments

Comments
 (0)