fix(discord): preserve distinct interaction occurrences (#121155)

This commit is contained in:
Peter Steinberger
2026-08-09 16:37:51 -07:00
committed by GitHub
parent 68e77c5ea7
commit 727e48240b
5 changed files with 151 additions and 77 deletions
@@ -201,17 +201,3 @@ export function formatModalSubmissionText(
}
return lines.join("\n");
}
export function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string {
const rawId =
interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData
? (interaction.rawData as { id?: unknown }).id
: undefined;
if (typeof rawId === "string" && rawId.trim()) {
return rawId.trim();
}
if (typeof rawId === "number" && Number.isFinite(rawId)) {
return String(rawId);
}
return `discord-interaction:${Date.now()}`;
}
@@ -20,7 +20,6 @@ export {
parseAgentComponentData,
parseDiscordComponentData,
parseDiscordModalId,
resolveDiscordInteractionId,
resolveInteractionCustomId,
resolveModalFieldValues,
} from "./agent-components-data.js";
@@ -8,12 +8,11 @@ import {
} from "../interactive-dispatch.js";
import type { TopLevelComponents } from "../internal/discord.js";
import { editDiscordComponentMessage } from "../send.components.js";
import {
resolveDiscordInteractionId,
type AgentComponentContext,
type AgentComponentInteraction,
type ComponentInteractionContext,
type DiscordChannelContext,
import type {
AgentComponentContext,
AgentComponentInteraction,
ComponentInteractionContext,
DiscordChannelContext,
} from "./agent-components-helpers.js";
const loadConversationRuntime = createLazyRuntimeModule(
@@ -144,10 +143,10 @@ export async function dispatchPluginDiscordInteractiveEvent(params: {
}
const dispatched = await dispatchDiscordPluginInteractiveHandler({
data: params.data,
interactionId: resolveDiscordInteractionId(params.interaction),
interactionId: params.interaction.id,
ctx: {
accountId: params.ctx.accountId,
interactionId: resolveDiscordInteractionId(params.interaction),
interactionId: params.interaction.id,
conversationId: normalizedConversationId,
parentConversationId: params.channelCtx.parentId,
guildId: params.interactionCtx.rawGuildId,
@@ -98,7 +98,9 @@ async function runAgentSystemControlInteraction(params: AgentSystemControlParams
enqueueSystemEvent(eventText, {
sessionKey: route.sessionKey,
contextKey: `${params.contextKeyPrefix}:${channelId}:${componentId}:${userId}`,
// The immutable interaction ID identifies one occurrence, preserving repeat clicks while
// deduplicating gateway replays of that same occurrence.
contextKey: `${params.contextKeyPrefix}:${channelId}:${componentId}:${userId}:${params.interaction.id}`,
});
await ackComponentInteraction({
@@ -1,15 +1,24 @@
// Discord tests cover monitor.agent components plugin behavior.
import { ChannelType } from "discord-api-types/v10";
import { ChannelType, ComponentType } from "discord-api-types/v10";
import { expectPairingReplyText } from "openclaw/plugin-sdk/channel-test-helpers";
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
import {
enqueueSystemEvent,
peekSystemEventEntries,
} from "openclaw/plugin-sdk/system-event-runtime";
import { peekSystemEvents, resetSystemEventsForTest } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
import {
ButtonInteraction,
ComponentData,
createInteraction,
StringSelectMenuInteraction,
type ComponentData,
} from "../internal/discord.js";
import {
createInternalComponentInteractionPayload,
createInternalTestClient,
} from "../internal/test-builders.test-support.js";
import {
enqueueSystemEventMock,
readAllowFromStoreMock,
@@ -37,37 +46,53 @@ describe("agent components", () => {
});
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
const createBaseDmInteraction = (overrides: Record<string, unknown> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const defer = vi.fn().mockResolvedValue(undefined);
const interaction = {
rawData: { channel_id: "dm-channel" },
user: { id: "123456789", username: "Alice", discriminator: "1234" },
defer,
reply,
...overrides,
};
return { interaction, defer, reply };
};
const createDmButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
const { interaction, defer, reply } = createBaseDmInteraction(
overrides as Record<string, unknown>,
const createDmInteraction = (params: { interactionId: string; data?: Record<string, unknown> }) =>
createInteraction(
createInternalTestClient(),
createInternalComponentInteractionPayload({
id: params.interactionId,
token: `token-${params.interactionId}`,
channel_id: "dm-channel",
user: {
id: "123456789",
username: "Alice",
discriminator: "1234",
global_name: null,
avatar: null,
},
data: params.data,
}),
);
const createDmButtonInteraction = (interactionId = "interaction-1") => {
const interaction = createDmInteraction({ interactionId });
if (!(interaction instanceof ButtonInteraction)) {
throw new Error("expected a Discord button interaction");
}
const defer = vi.spyOn(interaction, "defer").mockResolvedValue(undefined);
const reply = vi.spyOn(interaction, "reply").mockResolvedValue(undefined);
return {
interaction: interaction as unknown as ButtonInteraction,
interaction,
defer,
reply,
};
};
const createDmSelectInteraction = (overrides: Partial<StringSelectMenuInteraction> = {}) => {
const { interaction, defer, reply } = createBaseDmInteraction({
values: ["alpha"],
...(overrides as Record<string, unknown>),
const createDmSelectInteraction = (interactionId = "interaction-1") => {
const interaction = createDmInteraction({
interactionId,
data: {
component_type: ComponentType.StringSelect,
values: ["alpha"],
},
});
if (!(interaction instanceof StringSelectMenuInteraction)) {
throw new Error("expected a Discord string select interaction");
}
const defer = vi.spyOn(interaction, "defer").mockResolvedValue(undefined);
const reply = vi.spyOn(interaction, "reply").mockResolvedValue(undefined);
return {
interaction: interaction as unknown as StringSelectMenuInteraction,
interaction,
defer,
reply,
};
@@ -89,30 +114,34 @@ describe("agent components", () => {
return content;
};
const createBaseGroupDmInteraction = (overrides: Record<string, unknown> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const defer = vi.fn().mockResolvedValue(undefined);
const interaction = {
rawData: { channel_id: "group-dm-channel" },
channel: {
id: "group-dm-channel",
type: ChannelType.GroupDM,
name: "incident-room",
},
user: { id: "123456789", username: "Alice", discriminator: "1234" },
defer,
reply,
...overrides,
};
return { interaction, defer, reply };
};
const createGroupDmButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
const { interaction, defer, reply } = createBaseGroupDmInteraction(
overrides as Record<string, unknown>,
const createGroupDmButtonInteraction = (interactionId = "interaction-1") => {
const interaction = createInteraction(
createInternalTestClient(),
createInternalComponentInteractionPayload({
id: interactionId,
token: `token-${interactionId}`,
channel_id: "group-dm-channel",
channel: {
id: "group-dm-channel",
type: ChannelType.GroupDM,
name: "incident-room",
},
user: {
id: "123456789",
username: "Alice",
discriminator: "1234",
global_name: null,
avatar: null,
},
}),
);
if (!(interaction instanceof ButtonInteraction)) {
throw new Error("expected a Discord button interaction");
}
const defer = vi.spyOn(interaction, "defer").mockResolvedValue(undefined);
const reply = vi.spyOn(interaction, "reply").mockResolvedValue(undefined);
return {
interaction: interaction as unknown as ButtonInteraction,
interaction,
defer,
reply,
};
@@ -139,7 +168,7 @@ describe("agent components", () => {
"[Discord component: hello clicked by Alice#1234 (123456789)]",
{
sessionKey: defaultDmSessionKey,
contextKey: "discord:agent-button:dm-channel:hello:123456789",
contextKey: "discord:agent-button:dm-channel:hello:123456789:interaction-1",
},
);
if (params.expectPairingStoreRead) {
@@ -205,7 +234,7 @@ describe("agent components", () => {
});
expect(defer).not.toHaveBeenCalled();
expect(ctx).toEqual({
expect(ctx).toMatchObject({
channelId: "group-dm-channel",
user: { id: "123456789", username: "Alice", discriminator: "1234" },
username: "Alice#1234",
@@ -266,7 +295,7 @@ describe("agent components", () => {
"[Discord component: hello clicked by Alice#1234 (123456789)]",
{
sessionKey: defaultGroupDmSessionKey,
contextKey: "discord:agent-button:group-dm-channel:hello:123456789",
contextKey: "discord:agent-button:group-dm-channel:hello:123456789:interaction-1",
},
);
expect(peekSystemEvents(defaultDmSessionKey)).toStrictEqual([]);
@@ -329,7 +358,7 @@ describe("agent components", () => {
"[Discord select menu: hello interacted by Alice#1234 (123456789) (selected: alpha)]",
{
sessionKey: defaultDmSessionKey,
contextKey: "discord:agent-select:dm-channel:hello:123456789",
contextKey: "discord:agent-select:dm-channel:hello:123456789:interaction-1",
},
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
@@ -352,7 +381,7 @@ describe("agent components", () => {
"[Discord component: hello_cid clicked by Alice#1234 (123456789)]",
{
sessionKey: defaultDmSessionKey,
contextKey: "discord:agent-button:dm-channel:hello_cid:123456789",
contextKey: "discord:agent-button:dm-channel:hello_cid:123456789:interaction-1",
},
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
@@ -375,9 +404,68 @@ describe("agent components", () => {
"[Discord component: hello%2G clicked by Alice#1234 (123456789)]",
{
sessionKey: defaultDmSessionKey,
contextKey: "discord:agent-button:dm-channel:hello%2G:123456789",
contextKey: "discord:agent-button:dm-channel:hello%2G:123456789:interaction-1",
},
);
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
it.each(["button", "select"] as const)(
"queues distinct %s occurrences while deduplicating replayed occurrences",
async (kind) => {
const context = {
cfg: createCfg(),
accountId: "default",
dmPolicy: "allowlist" as const,
allowFrom: ["123456789"],
};
const control =
kind === "button" ? createAgentComponentButton(context) : createAgentSelectMenu(context);
const createInteractionForKind =
kind === "button" ? createDmButtonInteraction : createDmSelectInteraction;
const first = createInteractionForKind("interaction-1");
const second = createInteractionForKind("interaction-2");
const replay = createInteractionForKind("interaction-1");
await enqueueSystemEventMock.withImplementation(
(...args) => enqueueSystemEvent(...(args as Parameters<typeof enqueueSystemEvent>)),
async () => {
await control.run(first.interaction, { componentId: "hello" } as ComponentData);
enqueueSystemEvent("An unrelated event occurred", {
sessionKey: defaultDmSessionKey,
contextKey: "discord:test:intervening",
});
await control.run(second.interaction, { componentId: "hello" } as ComponentData);
await control.run(replay.interaction, { componentId: "hello" } as ComponentData);
expect(enqueueSystemEventMock.mock.results.map(({ value }) => value)).toEqual([
true,
true,
false,
]);
const eventText =
kind === "button"
? "[Discord component: hello clicked by Alice#1234 (123456789)]"
: "[Discord select menu: hello interacted by Alice#1234 (123456789) (selected: alpha)]";
expect(peekSystemEventEntries(defaultDmSessionKey)).toMatchObject([
{
text: eventText,
contextKey: `discord:agent-${kind}:dm-channel:hello:123456789:interaction-1`,
},
{
text: "An unrelated event occurred",
contextKey: "discord:test:intervening",
},
{
text: eventText,
contextKey: `discord:agent-${kind}:dm-channel:hello:123456789:interaction-2`,
},
]);
for (const { reply } of [first, second, replay]) {
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
}
},
);
},
);
});