From 6129fbf784a0ee85171c8ea4d1ca3a1765aeb381 Mon Sep 17 00:00:00 2001 From: Shakker Date: Sun, 2 Aug 2026 09:52:01 +0100 Subject: [PATCH] fix: invalidate removed Buzz mention members --- extensions/buzz/src/buzz-bus.mentions.test.ts | 43 ++++++++++++++++++- .../buzz/src/room-membership-tracker.ts | 27 +++++++++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/extensions/buzz/src/buzz-bus.mentions.test.ts b/extensions/buzz/src/buzz-bus.mentions.test.ts index 36d80f3ace44..3a5ca34f7329 100644 --- a/extensions/buzz/src/buzz-bus.mentions.test.ts +++ b/extensions/buzz/src/buzz-bus.mentions.test.ts @@ -14,6 +14,11 @@ const relayMocks = vi.hoisted(() => ({ membershipEvents: [] as Event[], subscriptions: [] as Array<{ filters: Filter[]; + handlers: { + onevent: (event: Event) => void; + oneose?: () => void; + onclose?: (reason: string) => void; + }; close: ReturnType; }>, })); @@ -39,10 +44,11 @@ vi.mock("nostr-tools", async (importOriginal) => { handlers: { onevent: (event: Event) => void; oneose?: () => void; + onclose?: (reason: string) => void; }, ) { const close = vi.fn(); - relayMocks.subscriptions.push({ filters, close }); + relayMocks.subscriptions.push({ filters, handlers, close }); if (filters.some((filter) => filter.kinds?.includes(39002))) { for (const event of relayMocks.membershipEvents) { handlers.onevent(event); @@ -251,6 +257,41 @@ describe("Buzz mention delivery", () => { await bus.close(); }); + it("stops mentioning a removed member before the signed roster refresh completes", async () => { + const explicitSender = `nostr:${nip19.npubEncode(SENDER_PUBLIC_KEY)}`; + const bus = await startBuzzBus({ + accountId: ACCOUNT_ID, + relayUrl: "wss://buzz.example.com", + privateKey: PRIVATE_KEY, + channelIds: [CHANNEL_ID], + onMessage: async () => {}, + }); + + await bus.sendText({ channelId: CHANNEL_ID, text: explicitSender }); + relayMocks.publish.mockClear(); + relayMocks.subscriptions + .find((entry) => subscriptionIncludesKind(entry, 40_099)) + ?.handlers.onevent({ + id: "member-removed-1", + kind: 40_099, + pubkey: RELAY_PUBLIC_KEY, + created_at: 1_700_000_001, + content: JSON.stringify({ type: "member_removed", target: SENDER_PUBLIC_KEY }), + sig: "e".repeat(128), + tags: [["h", CHANNEL_ID]], + }); + + expect(bus.directory.mentionMembers(CHANNEL_ID)).toEqual([ + expect.objectContaining({ publicKey: BOT_PUBLIC_KEY }), + ]); + await expect(bus.sendText({ channelId: CHANNEL_ID, text: explicitSender })).rejects.toThrow( + "is not a current room member", + ); + expect(relayMocks.publish).not.toHaveBeenCalled(); + + await bus.close(); + }); + it("keeps mention-free active sends off the room roster path", async () => { const bus = await startBuzzBus({ accountId: ACCOUNT_ID, diff --git a/extensions/buzz/src/room-membership-tracker.ts b/extensions/buzz/src/room-membership-tracker.ts index fdc1907e5ab1..45b6435f06cb 100644 --- a/extensions/buzz/src/room-membership-tracker.ts +++ b/extensions/buzz/src/room-membership-tracker.ts @@ -90,6 +90,28 @@ export async function createBuzzRoomMembershipTracker(params: { const refreshes = new Map(); let membershipQueryTail = Promise.resolve(); const memberships = await queryBuzzRoomMemberships(params); + const effectiveMemberships = (): ReadonlyMap => { + if (blockedRooms.size === 0 && deniedMembers.size === 0) { + return memberships; + } + const effective = new Map(); + for (const [channelId, membership] of memberships) { + if (blockedRooms.has(channelId)) { + continue; + } + const denied = deniedMembers.get(channelId); + if (!denied || denied.size === 0) { + effective.set(channelId, membership); + continue; + } + effective.set(channelId, { + ...membership, + members: new Set([...membership.members].filter((publicKey) => !denied.has(publicKey))), + roles: new Map([...membership.roles].filter(([publicKey]) => !denied.has(publicKey))), + }); + } + return effective; + }; const isMember = (channelId: string, publicKey: string) => !blockedRooms.has(channelId) && !deniedMembers.get(channelId)?.has(publicKey.trim().toLowerCase()) && @@ -181,7 +203,7 @@ export async function createBuzzRoomMembershipTracker(params: { pendingMemberships.delete(channelId); deniedMembers.delete(channelId); blockedRooms.delete(channelId); - params.onMembershipsChanged?.(memberships); + params.onMembershipsChanged?.(effectiveMemberships()); return; } if (state.generation !== state.lastAttemptedGeneration) { @@ -255,6 +277,7 @@ export async function createBuzzRoomMembershipTracker(params: { if (change.targetPublicKey === params.botPublicKey) { blockedRooms.add(channelId); } + params.onMembershipsChanged?.(effectiveMemberships()); return refreshMembershipOnce(channelId); }; const handleRoomEvent = (event: Event, reservation?: BuzzReplayDispatchReservation) => { @@ -358,7 +381,7 @@ export async function createBuzzRoomMembershipTracker(params: { } return { - memberships: () => memberships, + memberships: effectiveMemberships, catchUpHistory: async () => { for (const channelId of params.channelIds) { const page = historyPages.get(channelId);