mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
fix(whatsapp): fence stale group metadata lookups (#128283)
This commit is contained in:
committed by
GitHub
parent
33322b0ab8
commit
8e04df0205
@@ -95,8 +95,7 @@ function readGroupMetadataCacheEntry<T extends WhatsAppGroupMetadataCacheEntry>(
|
||||
export function createWhatsAppGroupMetadataCacheOwner(params: GroupMetadataCacheOwnerParams) {
|
||||
const reconnectCache = params.reconnectCache ?? new Map();
|
||||
const localCache = new Map<string, LocalGroupMetadataCacheEntry>();
|
||||
const publishedJids = new Set<string>();
|
||||
const invalidatedJids = new Set<string>();
|
||||
const groupMetadataGenerations = new Map<string, object>();
|
||||
const detachListeners: Array<() => void> = [];
|
||||
let closed = false;
|
||||
let started = false;
|
||||
@@ -133,62 +132,76 @@ export function createWhatsAppGroupMetadataCacheOwner(params: GroupMetadataCache
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
groupMetadataGenerations.set(jid, {});
|
||||
rememberWhatsAppBaileysCacheEntry(params.baileysCache, jid, meta, GROUP_META_TTL_MS);
|
||||
publishedJids.add(jid);
|
||||
invalidatedJids.delete(jid);
|
||||
rememberGroupMetadataCacheEntry(reconnectCache, jid, summarizeForReconnect(meta));
|
||||
localCache.delete(jid);
|
||||
};
|
||||
|
||||
const forgetFullMetadata = (jid: string) => {
|
||||
groupMetadataGenerations.set(jid, {});
|
||||
params.baileysCache?.delete(jid);
|
||||
reconnectCache.delete(jid);
|
||||
localCache.delete(jid);
|
||||
publishedJids.delete(jid);
|
||||
invalidatedJids.add(jid);
|
||||
};
|
||||
|
||||
const get = async (jid: string): Promise<LocalGroupMetadataCacheEntry> => {
|
||||
const cached = readGroupMetadataCacheEntry(localCache, jid);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
try {
|
||||
const hydratedEntry = params.baileysCache?.get(jid);
|
||||
const providerMetadata = params.baileysCache
|
||||
? readWhatsAppBaileysCacheEntry(params.baileysCache, jid)
|
||||
: undefined;
|
||||
const hydratedMetadata = providerMetadata?.participants?.length
|
||||
? providerMetadata
|
||||
: undefined;
|
||||
const meta =
|
||||
hydratedMetadata ?? (await (params.getCurrentSock() ?? params.sock).groupMetadata(jid));
|
||||
if (!hydratedMetadata) {
|
||||
rememberWhatsAppBaileysCacheEntry(params.baileysCache, jid, meta, GROUP_META_TTL_MS);
|
||||
for (;;) {
|
||||
if (closed) {
|
||||
return { expires: resolveGroupMetadataExpiresAt() ?? 0 };
|
||||
}
|
||||
publishedJids.add(jid);
|
||||
const entry = await summarize(meta);
|
||||
if (hydratedMetadata && hydratedEntry) {
|
||||
// Reusing provider-owned membership must not extend its authoritative freshness window.
|
||||
entry.expires = hydratedEntry.expiresAt;
|
||||
const cached = readGroupMetadataCacheEntry(localCache, jid);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
rememberGroupMetadataCacheEntry(reconnectCache, jid, {
|
||||
subject: entry.subject,
|
||||
expires: entry.expires,
|
||||
});
|
||||
rememberGroupMetadataCacheEntry(localCache, jid, entry);
|
||||
return entry;
|
||||
} catch (error) {
|
||||
const hydrated = readGroupMetadataCacheEntry(reconnectCache, jid);
|
||||
if (hydrated) {
|
||||
rememberGroupMetadataCacheEntry(localCache, jid, hydrated);
|
||||
params.logVerbose(
|
||||
`Using cached group metadata for ${jid} after fetch failure: ${String(error)}`,
|
||||
);
|
||||
return hydrated;
|
||||
const generation = groupMetadataGenerations.get(jid);
|
||||
try {
|
||||
const hydratedEntry = params.baileysCache?.get(jid);
|
||||
const providerMetadata = params.baileysCache
|
||||
? readWhatsAppBaileysCacheEntry(params.baileysCache, jid)
|
||||
: undefined;
|
||||
const hydratedMetadata = providerMetadata?.participants?.length
|
||||
? providerMetadata
|
||||
: undefined;
|
||||
const meta =
|
||||
hydratedMetadata ?? (await (params.getCurrentSock() ?? params.sock).groupMetadata(jid));
|
||||
if (closed || groupMetadataGenerations.get(jid) !== generation) {
|
||||
continue;
|
||||
}
|
||||
const entry = await summarize(meta);
|
||||
// Membership updates and shutdown can happen during either provider lookup or LID mapping.
|
||||
// Publish all caches together only while this JID still owns its exact live generation.
|
||||
if (closed || groupMetadataGenerations.get(jid) !== generation) {
|
||||
continue;
|
||||
}
|
||||
if (hydratedMetadata && hydratedEntry) {
|
||||
// Reusing provider-owned membership must not extend its authoritative freshness window.
|
||||
entry.expires = hydratedEntry.expiresAt;
|
||||
} else {
|
||||
rememberWhatsAppBaileysCacheEntry(params.baileysCache, jid, meta, GROUP_META_TTL_MS);
|
||||
}
|
||||
groupMetadataGenerations.set(jid, {});
|
||||
rememberGroupMetadataCacheEntry(reconnectCache, jid, {
|
||||
subject: entry.subject,
|
||||
expires: entry.expires,
|
||||
});
|
||||
rememberGroupMetadataCacheEntry(localCache, jid, entry);
|
||||
return entry;
|
||||
} catch (error) {
|
||||
if (closed || groupMetadataGenerations.get(jid) !== generation) {
|
||||
continue;
|
||||
}
|
||||
const hydrated = readGroupMetadataCacheEntry(reconnectCache, jid);
|
||||
if (hydrated) {
|
||||
rememberGroupMetadataCacheEntry(localCache, jid, hydrated);
|
||||
params.logVerbose(
|
||||
`Using cached group metadata for ${jid} after fetch failure: ${String(error)}`,
|
||||
);
|
||||
return hydrated;
|
||||
}
|
||||
params.logVerbose(`Failed to fetch group metadata for ${jid}: ${String(error)}`);
|
||||
return { expires: resolveGroupMetadataExpiresAt() ?? 0 };
|
||||
}
|
||||
params.logVerbose(`Failed to fetch group metadata for ${jid}: ${String(error)}`);
|
||||
return { expires: resolveGroupMetadataExpiresAt() ?? 0 };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -268,10 +281,10 @@ export function createWhatsAppGroupMetadataCacheOwner(params: GroupMetadataCache
|
||||
return;
|
||||
}
|
||||
for (const [jid, meta] of Object.entries(groups ?? {})) {
|
||||
if (meta && !publishedJids.has(jid) && !invalidatedJids.has(jid)) {
|
||||
if (meta && !groupMetadataGenerations.has(jid)) {
|
||||
rememberGroupMetadataCacheEntry(reconnectCache, jid, summarizeForReconnect(meta));
|
||||
rememberWhatsAppBaileysCacheEntry(params.baileysCache, jid, meta, GROUP_META_TTL_MS);
|
||||
publishedJids.add(jid);
|
||||
groupMetadataGenerations.set(jid, {});
|
||||
}
|
||||
}
|
||||
params.logVerbose(
|
||||
|
||||
@@ -284,6 +284,233 @@ describe("web monitor inbox metadata cache", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "participant updates",
|
||||
publishUpdate: (sock: ReturnType<typeof getSock>) =>
|
||||
sock.ev.emit("group-participants.update", { id: "123@g.us" }),
|
||||
expectedMetadataRequests: 2,
|
||||
},
|
||||
{
|
||||
name: "partial group updates",
|
||||
publishUpdate: (sock: ReturnType<typeof getSock>) =>
|
||||
sock.ev.emit("groups.update", [{ id: "123@g.us" }]),
|
||||
expectedMetadataRequests: 2,
|
||||
},
|
||||
{
|
||||
name: "complete group updates",
|
||||
publishUpdate: (sock: ReturnType<typeof getSock>) =>
|
||||
sock.ev.emit("groups.update", [
|
||||
groupMetadata({
|
||||
subject: "Current Group",
|
||||
participants: ["15559876543@s.whatsapp.net"],
|
||||
}),
|
||||
]),
|
||||
expectedMetadataRequests: 1,
|
||||
},
|
||||
])(
|
||||
"group metadata cache never restores stale members after $name during a live fetch",
|
||||
async ({ publishUpdate, expectedMetadataRequests }) => {
|
||||
const groupMetadataCache: NonNullable<InboxMonitorOptions["groupMetadataCache"]> = new Map();
|
||||
const sock = getSock();
|
||||
let resolveStaleMetadata!: (metadata: GroupMetadata) => void;
|
||||
sock.groupMetadata
|
||||
.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<GroupMetadata>((resolve) => {
|
||||
resolveStaleMetadata = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
groupMetadata({
|
||||
subject: "Current Group",
|
||||
participants: ["15559876543@s.whatsapp.net"],
|
||||
}),
|
||||
);
|
||||
|
||||
const { listener, baileysCache } = await startInboxMonitorWithBaileysCache({
|
||||
groupMetadataCache,
|
||||
});
|
||||
try {
|
||||
const sending = listener.sendMessage(
|
||||
"123@g.us",
|
||||
"removed @15551234567 current @15559876543",
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(sock.groupMetadata).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
publishUpdate(sock);
|
||||
resolveStaleMetadata(
|
||||
groupMetadata({
|
||||
subject: "Former Group",
|
||||
participants: ["15551234567@s.whatsapp.net"],
|
||||
}),
|
||||
);
|
||||
await sending;
|
||||
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("123@g.us", {
|
||||
text: "removed @15551234567 current @15559876543",
|
||||
mentions: ["15559876543@s.whatsapp.net"],
|
||||
});
|
||||
expect(sock.groupMetadata).toHaveBeenCalledTimes(expectedMetadataRequests);
|
||||
await expectCachedGroupMetadata(baileysCache, {
|
||||
id: "123@g.us",
|
||||
subject: "Current Group",
|
||||
participants: [{ id: "15559876543@s.whatsapp.net" }],
|
||||
});
|
||||
expect(groupMetadataCache.get("123@g.us")?.subject).toBe("Current Group");
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("group metadata cache discards participants invalidated during identity normalization", async () => {
|
||||
const groupMetadataCache: NonNullable<InboxMonitorOptions["groupMetadataCache"]> = new Map();
|
||||
const sock = getSock();
|
||||
let resolveFormerParticipant!: (jid: string) => void;
|
||||
sock.signalRepository.lidMapping.getPNForLID.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<string>((resolve) => {
|
||||
resolveFormerParticipant = resolve;
|
||||
}),
|
||||
);
|
||||
sock.groupMetadata
|
||||
.mockResolvedValueOnce(
|
||||
groupMetadata({
|
||||
subject: "Former Group",
|
||||
participants: ["277038292303944@lid"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
groupMetadata({
|
||||
subject: "Current Group",
|
||||
participants: ["15559876543@s.whatsapp.net"],
|
||||
}),
|
||||
);
|
||||
|
||||
const { listener, baileysCache } = await startInboxMonitorWithBaileysCache({
|
||||
groupMetadataCache,
|
||||
});
|
||||
try {
|
||||
const sending = listener.sendMessage("123@g.us", "removed @15551234567 current @15559876543");
|
||||
await vi.waitFor(() => {
|
||||
expect(sock.signalRepository.lidMapping.getPNForLID).toHaveBeenCalledWith(
|
||||
"277038292303944@lid",
|
||||
);
|
||||
});
|
||||
|
||||
sock.ev.emit("group-participants.update", { id: "123@g.us" });
|
||||
resolveFormerParticipant("15551234567@s.whatsapp.net");
|
||||
await sending;
|
||||
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("123@g.us", {
|
||||
text: "removed @15551234567 current @15559876543",
|
||||
mentions: ["15559876543@s.whatsapp.net"],
|
||||
});
|
||||
await expectCachedGroupMetadata(baileysCache, {
|
||||
id: "123@g.us",
|
||||
subject: "Current Group",
|
||||
participants: [{ id: "15559876543@s.whatsapp.net" }],
|
||||
});
|
||||
expect(groupMetadataCache.get("123@g.us")?.subject).toBe("Current Group");
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("group membership updates do not invalidate concurrent metadata for another group", async () => {
|
||||
const sock = getSock();
|
||||
let resolveStaleMetadata!: (metadata: GroupMetadata) => void;
|
||||
sock.groupMetadata
|
||||
.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<GroupMetadata>((resolve) => {
|
||||
resolveStaleMetadata = resolve;
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
groupMetadata({
|
||||
id: "456@g.us",
|
||||
subject: "Unrelated Group",
|
||||
participants: ["15551111111@s.whatsapp.net"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
groupMetadata({
|
||||
subject: "Current Group",
|
||||
participants: ["15559876543@s.whatsapp.net"],
|
||||
}),
|
||||
);
|
||||
|
||||
const { listener, baileysCache } = await startInboxMonitorWithBaileysCache();
|
||||
try {
|
||||
const affectedSend = listener.sendMessage("123@g.us", "current @15559876543");
|
||||
await vi.waitFor(() => {
|
||||
expect(sock.groupMetadata).toHaveBeenCalledOnce();
|
||||
});
|
||||
const unrelatedSend = listener.sendMessage("456@g.us", "unrelated @15551111111");
|
||||
await unrelatedSend;
|
||||
|
||||
sock.ev.emit("group-participants.update", { id: "123@g.us" });
|
||||
resolveStaleMetadata(
|
||||
groupMetadata({
|
||||
subject: "Former Group",
|
||||
participants: ["15551234567@s.whatsapp.net"],
|
||||
}),
|
||||
);
|
||||
await affectedSend;
|
||||
|
||||
expect(sock.groupMetadata).toHaveBeenCalledTimes(3);
|
||||
expect(sock.sendMessage).toHaveBeenCalledWith("456@g.us", {
|
||||
text: "unrelated @15551111111",
|
||||
mentions: ["15551111111@s.whatsapp.net"],
|
||||
});
|
||||
await expectCachedGroupMetadata(baileysCache, {
|
||||
id: "456@g.us",
|
||||
subject: "Unrelated Group",
|
||||
participants: [{ id: "15551111111@s.whatsapp.net" }],
|
||||
});
|
||||
} finally {
|
||||
await listener.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("group metadata cache never republishes a live fetch after its owner closes", async () => {
|
||||
const groupMetadataCache: NonNullable<InboxMonitorOptions["groupMetadataCache"]> = new Map();
|
||||
const sock = getSock();
|
||||
let resolveStaleMetadata!: (metadata: GroupMetadata) => void;
|
||||
sock.groupMetadata.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<GroupMetadata>((resolve) => {
|
||||
resolveStaleMetadata = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const { listener, baileysCache } = await startInboxMonitorWithBaileysCache({
|
||||
groupMetadataCache,
|
||||
});
|
||||
const sending = listener.sendMessage("123@g.us", "closed @15551234567").catch(() => undefined);
|
||||
await vi.waitFor(() => {
|
||||
expect(sock.groupMetadata).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
await listener.close();
|
||||
resolveStaleMetadata(
|
||||
groupMetadata({
|
||||
subject: "Closed Group",
|
||||
participants: ["15551234567@s.whatsapp.net"],
|
||||
}),
|
||||
);
|
||||
await sending;
|
||||
|
||||
expect(groupMetadataCache.has("123@g.us")).toBe(false);
|
||||
await expect(
|
||||
baileysCache.socketOptions.cachedGroupMetadata("123@g.us"),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("group metadata cache invalidates partial group and participant updates", async () => {
|
||||
const groupMetadataCache: NonNullable<InboxMonitorOptions["groupMetadataCache"]> = new Map();
|
||||
const { listener, sock, baileysCache } = await startInboxMonitorWithBaileysCache({
|
||||
|
||||
Reference in New Issue
Block a user