diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c6f127b71..d0e2fa5ff34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/logging: keep deferred channel startup logs on the subsystem logger, so Slack, Discord, Telegram, and voice-call startup messages keep timestamped prefixes. Thanks @vincentkoc. +- Discord/components: consume every button or select in a non-reusable component message after the first authorized click, so single-use panels cannot fire sibling callbacks. Fixes #54227. Thanks @fujiwarakasei. - Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has `reactionNotifications: "off"`, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120. - CLI sessions: preserve explicit manual-attach reuse bindings so trusted CLI sessions are not invalidated on the first turn when auth, prompt, or MCP fingerprints drift. Fixes #75849. Thanks @alfredjbclaw. - Telegram/streaming: keep partial preview streaming enabled for plain reply-to replies, disabling drafts only for real native quote excerpts that require Telegram quote parameters. Fixes #73505. Thanks @choury. diff --git a/extensions/discord/src/components-registry.ts b/extensions/discord/src/components-registry.ts index 8d581548bda..feb1a372b8f 100644 --- a/extensions/discord/src/components-registry.ts +++ b/extensions/discord/src/components-registry.ts @@ -222,6 +222,31 @@ function deletePersistentEntry(params: { void store.delete(params.id).catch(disablePersistentComponentRegistry); } +function resolveComponentConsumptionIds(entry: DiscordComponentEntry): string[] { + if (!entry.consumptionGroupId) { + return [entry.id]; + } + const ids = entry.consumptionGroupEntryIds?.filter((id) => typeof id === "string" && id) ?? []; + return ids.length > 0 ? Array.from(new Set(ids)) : [entry.id]; +} + +function deleteComponentConsumptionGroup(entry: DiscordComponentEntry): void { + const store = getComponentEntries(); + for (const id of resolveComponentConsumptionIds(entry)) { + store.delete(id); + } +} + +function deletePersistentComponentConsumptionGroup(entry: DiscordComponentEntry): void { + const store = getPersistentComponentStore(); + if (!store) { + return; + } + for (const id of resolveComponentConsumptionIds(entry)) { + void store.delete(id).catch(disablePersistentComponentRegistry); + } +} + async function resolvePersistentRegistryEntry(params: { id: string; consume?: boolean; @@ -270,7 +295,11 @@ export function resolveDiscordComponentEntry(params: { id: string; consume?: boolean; }): DiscordComponentEntry | null { - return resolveEntry(getComponentEntries(), params); + const entry = resolveEntry(getComponentEntries(), params); + if (entry && params.consume !== false) { + deleteComponentConsumptionGroup(entry); + } + return entry; } export async function resolveDiscordComponentEntryWithPersistence(params: { @@ -280,14 +309,18 @@ export async function resolveDiscordComponentEntryWithPersistence(params: { const inMemory = resolveDiscordComponentEntry(params); if (inMemory) { if (params.consume !== false) { - deletePersistentEntry({ ...params, openStore: getPersistentComponentStore }); + deletePersistentComponentConsumptionGroup(inMemory); } return inMemory; } - return await resolvePersistentRegistryEntry({ + const persisted = await resolvePersistentRegistryEntry({ ...params, openStore: getPersistentComponentStore, }); + if (persisted && params.consume !== false) { + deletePersistentComponentConsumptionGroup(persisted); + } + return persisted; } export function resolveDiscordModalEntry(params: { diff --git a/extensions/discord/src/components.builders.ts b/extensions/discord/src/components.builders.ts index 3ee98c71994..b6c0c9f85bd 100644 --- a/extensions/discord/src/components.builders.ts +++ b/extensions/discord/src/components.builders.ts @@ -229,6 +229,7 @@ export function buildDiscordComponentMessage(params: { accountId?: string; }): DiscordComponentBuildResult { const entries: DiscordComponentEntry[] = []; + const consumptionGroupId = createShortId("grp_"); const modals: DiscordModalEntry[] = []; const components: TopLevelComponents[] = []; const containerChildren: Array< @@ -255,6 +256,7 @@ export function buildDiscordComponentMessage(params: { agentId: params.agentId, accountId: params.accountId, reusable: entry.reusable ?? params.spec.reusable, + consumptionGroupId, }); }; @@ -392,6 +394,10 @@ export function buildDiscordComponentMessage(params: { const container = new Container(containerChildren, params.spec.container); components.push(container); + const consumptionGroupEntryIds = entries.map((entry) => entry.id); + for (const entry of entries) { + entry.consumptionGroupEntryIds = consumptionGroupEntryIds; + } return { components, entries, modals }; } diff --git a/extensions/discord/src/components.test.ts b/extensions/discord/src/components.test.ts index 5f0a5b590e4..b01830956bf 100644 --- a/extensions/discord/src/components.test.ts +++ b/extensions/discord/src/components.test.ts @@ -118,6 +118,41 @@ describe("discord component registry", () => { expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull(); }); + it("consumes sibling entries from the same non-reusable component message", () => { + const result = buildDiscordComponentMessage({ + spec: { + text: "Confirm action", + blocks: [ + { + type: "actions", + buttons: [ + { label: "Confirm", callbackData: "confirm" }, + { label: "Cancel", callbackData: "cancel" }, + ], + }, + ], + }, + }); + const confirm = result.entries.find((entry) => entry.label === "Confirm"); + const cancel = result.entries.find((entry) => entry.label === "Cancel"); + expect(confirm?.consumptionGroupId).toBeTruthy(); + expect(cancel?.consumptionGroupId).toBe(confirm?.consumptionGroupId); + expect(confirm?.consumptionGroupEntryIds).toEqual( + expect.arrayContaining([confirm?.id, cancel?.id]), + ); + + registerDiscordComponentEntries({ + entries: result.entries, + modals: [], + messageId: "msg_1", + ttlMs: 1000, + }); + + const consumed = resolveDiscordComponentEntry({ id: confirm?.id ?? "" }); + expect(consumed?.label).toBe("Confirm"); + expect(resolveDiscordComponentEntry({ id: cancel?.id ?? "", consume: false })).toBeNull(); + }); + it("shares registry state across duplicate module instances", async () => { const first = (await import( `${componentsRegistryModuleUrl}?t=first-${Date.now()}` @@ -208,6 +243,49 @@ describe("discord component registry", () => { expect(openKeyedStore).toHaveBeenCalledTimes(4); }); + it("deletes sibling persistent component entries when a group entry is consumed", async () => { + const componentDelete = vi.fn().mockResolvedValue(true); + const componentStore = { + register: vi.fn(), + lookup: vi.fn(), + consume: vi.fn().mockResolvedValue({ + version: 1, + entry: { + id: "btn_confirm", + kind: "button", + label: "Confirm", + consumptionGroupId: "grp_1", + consumptionGroupEntryIds: ["btn_confirm", "btn_cancel"], + }, + }), + delete: componentDelete, + }; + const modalStore = { + register: vi.fn(), + lookup: vi.fn(), + consume: vi.fn(), + delete: vi.fn(), + }; + const openKeyedStore = vi.fn((opts: { namespace: string }) => + opts.namespace === "discord.components" ? componentStore : modalStore, + ); + const { setDiscordRuntime } = await import("./runtime.js"); + setDiscordRuntime({ + state: { openKeyedStore }, + logging: { getChildLogger: () => ({ warn: vi.fn() }) }, + } as never); + + clearDiscordComponentEntries(); + await expect( + resolveDiscordComponentEntryWithPersistence({ id: "btn_confirm" }), + ).resolves.toMatchObject({ + id: "btn_confirm", + }); + + await vi.waitFor(() => expect(componentDelete).toHaveBeenCalledWith("btn_cancel")); + expect(componentDelete).toHaveBeenCalledWith("btn_confirm"); + }); + it("falls back to the in-memory registry when persistent state cannot open", async () => { const warn = vi.fn(); const { setDiscordRuntime } = await import("./runtime.js"); diff --git a/extensions/discord/src/components.types.ts b/extensions/discord/src/components.types.ts index 480fc04e1ac..2f7500ba32b 100644 --- a/extensions/discord/src/components.types.ts +++ b/extensions/discord/src/components.types.ts @@ -141,6 +141,8 @@ export type DiscordComponentEntry = { agentId?: string; accountId?: string; reusable?: boolean; + consumptionGroupId?: string; + consumptionGroupEntryIds?: string[]; allowedUsers?: string[]; messageId?: string; createdAt?: number;