mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:00:44 +00:00
fix(discord): consume component panels once
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -141,6 +141,8 @@ export type DiscordComponentEntry = {
|
||||
agentId?: string;
|
||||
accountId?: string;
|
||||
reusable?: boolean;
|
||||
consumptionGroupId?: string;
|
||||
consumptionGroupEntryIds?: string[];
|
||||
allowedUsers?: string[];
|
||||
messageId?: string;
|
||||
createdAt?: number;
|
||||
|
||||
Reference in New Issue
Block a user