fix(discord): consume component panels once

This commit is contained in:
Peter Steinberger
2026-05-02 05:09:00 +01:00
parent 37a253834a
commit c76ee644c2
5 changed files with 123 additions and 3 deletions

View File

@@ -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.

View File

@@ -222,6 +222,31 @@ function deletePersistentEntry<T extends { id: string }>(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<T extends { id: string }>(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: {

View File

@@ -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 };
}

View File

@@ -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");

View File

@@ -141,6 +141,8 @@ export type DiscordComponentEntry = {
agentId?: string;
accountId?: string;
reusable?: boolean;
consumptionGroupId?: string;
consumptionGroupEntryIds?: string[];
allowedUsers?: string[];
messageId?: string;
createdAt?: number;