Discord: stop deferring component picker clicks

This commit is contained in:
huntharo
2026-03-20 18:11:13 -04:00
parent d3c9e772c9
commit c63e9443ac
7 changed files with 172 additions and 51 deletions

View File

@@ -509,6 +509,7 @@ async function handleDiscordComponentEvent(params: {
interaction: params.interaction,
label: params.label,
componentLabel: params.componentLabel,
defer: false,
});
if (!interactionCtx) {
return;
@@ -814,6 +815,7 @@ export class AgentComponentButton extends Button {
interaction,
label: "agent button",
componentLabel: "button",
defer: false,
});
if (!interactionCtx) {
return;
@@ -903,6 +905,7 @@ export class AgentSelectMenu extends StringSelectMenu {
interaction,
label: "agent select",
componentLabel: "select menu",
defer: false,
});
if (!interactionCtx) {
return;

View File

@@ -199,7 +199,7 @@ describe("agent components", () => {
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledTimes(1);
const pairingText = String(reply.mock.calls[0]?.[0]?.content ?? "");
expect(pairingText).toContain("Pairing code:");
@@ -220,8 +220,11 @@ describe("agent components", () => {
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "You are not authorized to use this button." });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({
content: "You are not authorized to use this button.",
ephemeral: true,
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -237,8 +240,8 @@ describe("agent components", () => {
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
@@ -255,8 +258,8 @@ describe("agent components", () => {
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -272,8 +275,11 @@ describe("agent components", () => {
await button.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "DM interactions are disabled." });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({
content: "DM interactions are disabled.",
ephemeral: true,
});
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -290,8 +296,8 @@ describe("agent components", () => {
await select.run(interaction, { componentId: "hello" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
});
@@ -307,8 +313,8 @@ describe("agent components", () => {
await button.run(interaction, { cid: "hello_cid" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect.stringContaining("hello_cid"),
expect.any(Object),
@@ -327,8 +333,8 @@ describe("agent components", () => {
await button.run(interaction, { cid: "hello%2G" } as ComponentData);
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(defer).not.toHaveBeenCalled();
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
expect.stringContaining("hello%2G"),
expect.any(Object),
@@ -537,7 +543,7 @@ describe("discord component interactions", () => {
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
@@ -554,7 +560,7 @@ describe("discord component interactions", () => {
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(lastDispatchCtx?.BodyForAgent).toBe("/codex_resume --browse-projects");
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});
@@ -583,7 +589,7 @@ describe("discord component interactions", () => {
await select.run(interaction, { cid: "sel_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(lastDispatchCtx?.BodyForAgent).toBe('Selected Alpha from "Pick".');
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});
@@ -621,7 +627,10 @@ describe("discord component interactions", () => {
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "You are not authorized to use this button." });
expect(reply).toHaveBeenCalledWith({
content: "You are not authorized to use this button.",
ephemeral: true,
});
expect(dispatchReplyMock).not.toHaveBeenCalled();
expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull();
});
@@ -885,7 +894,7 @@ describe("discord component interactions", () => {
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1);
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
});

View File

@@ -1,7 +1,7 @@
import { ChannelType } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { registerDiscordComponentEntries } from "./components-registry.js";
import { sendDiscordComponentMessage } from "./send.components.js";
import { editDiscordComponentMessage, sendDiscordComponentMessage } from "./send.components.js";
import { makeDiscordRest } from "./send.test-harness.js";
const loadConfigMock = vi.hoisted(() => vi.fn(() => ({ session: { dmScope: "main" } })));
@@ -52,4 +52,39 @@ describe("sendDiscordComponentMessage", () => {
const args = registerMock.mock.calls[0]?.[0];
expect(args?.entries[0]?.sessionKey).toBe("agent:main:discord:channel:dm-1");
});
it("edits component messages and refreshes component registry entries", async () => {
const { rest, patchMock, getMock } = makeDiscordRest();
getMock.mockResolvedValueOnce({
type: ChannelType.GuildText,
id: "chan-1",
});
patchMock.mockResolvedValueOnce({ id: "msg1", channel_id: "chan-1" });
await editDiscordComponentMessage(
"channel:chan-1",
"msg1",
{
text: "Updated picker",
blocks: [{ type: "actions", buttons: [{ label: "Tap" }] }],
},
{
rest,
token: "t",
sessionKey: "agent:main:discord:channel:chan-1",
agentId: "main",
},
);
expect(patchMock).toHaveBeenCalledWith(
expect.stringContaining("/channels/chan-1/messages/msg1"),
expect.objectContaining({
body: expect.any(Object),
}),
);
expect(registerMock).toHaveBeenCalledTimes(1);
const args = registerMock.mock.calls[0]?.[0];
expect(args?.messageId).toBe("msg1");
expect(args?.entries[0]?.sessionKey).toBe("agent:main:discord:channel:chan-1");
});
});

View File

@@ -54,38 +54,29 @@ type DiscordComponentSendOpts = {
filename?: string;
};
export async function sendDiscordComponentMessage(
to: string,
spec: DiscordComponentMessageSpec,
opts: DiscordComponentSendOpts = {},
): Promise<DiscordSendResult> {
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const { channelId } = await resolveChannelId(rest, recipient, request);
const channelType = await resolveDiscordChannelType(rest, channelId);
if (channelType && DISCORD_FORUM_LIKE_TYPES.has(channelType)) {
throw new Error("Discord components are not supported in forum-style channels");
}
async function buildDiscordComponentPayload(params: {
spec: DiscordComponentMessageSpec;
opts: DiscordComponentSendOpts;
accountId: string;
}): Promise<{
body: ReturnType<typeof stripUndefinedFields>;
buildResult: ReturnType<typeof buildDiscordComponentMessage>;
}> {
const buildResult = buildDiscordComponentMessage({
spec,
sessionKey: opts.sessionKey,
agentId: opts.agentId,
accountId: accountInfo.accountId,
spec: params.spec,
sessionKey: params.opts.sessionKey,
agentId: params.opts.agentId,
accountId: params.accountId,
});
const flags = buildDiscordComponentMessageFlags(buildResult.components);
const finalFlags = opts.silent
const finalFlags = params.opts.silent
? (flags ?? 0) | SUPPRESS_NOTIFICATIONS_FLAG
: (flags ?? undefined);
const messageReference = opts.replyTo
? { message_id: opts.replyTo, fail_if_not_exists: false }
const messageReference = params.opts.replyTo
? { message_id: params.opts.replyTo, fail_if_not_exists: false }
: undefined;
const attachmentNames = extractComponentAttachmentNames(spec);
const attachmentNames = extractComponentAttachmentNames(params.spec);
const uniqueAttachmentNames = [...new Set(attachmentNames)];
if (uniqueAttachmentNames.length > 1) {
throw new Error(
@@ -94,9 +85,11 @@ export async function sendDiscordComponentMessage(
}
const expectedAttachmentName = uniqueAttachmentNames[0];
let files: MessagePayloadFile[] | undefined;
if (opts.mediaUrl) {
const media = await loadWebMedia(opts.mediaUrl, { localRoots: opts.mediaLocalRoots });
const filenameOverride = opts.filename?.trim();
if (params.opts.mediaUrl) {
const media = await loadWebMedia(params.opts.mediaUrl, {
localRoots: params.opts.mediaLocalRoots,
});
const filenameOverride = params.opts.filename?.trim();
const fileName = filenameOverride || media.fileName || "upload";
if (expectedAttachmentName && expectedAttachmentName !== fileName) {
throw new Error(
@@ -121,6 +114,32 @@ export async function sendDiscordComponentMessage(
...(messageReference ? { message_reference: messageReference } : {}),
});
return { body, buildResult };
}
export async function sendDiscordComponentMessage(
to: string,
spec: DiscordComponentMessageSpec,
opts: DiscordComponentSendOpts = {},
): Promise<DiscordSendResult> {
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const { channelId } = await resolveChannelId(rest, recipient, request);
const channelType = await resolveDiscordChannelType(rest, channelId);
if (channelType && DISCORD_FORUM_LIKE_TYPES.has(channelType)) {
throw new Error("Discord components are not supported in forum-style channels");
}
const { body, buildResult } = await buildDiscordComponentPayload({
spec,
opts,
accountId: accountInfo.accountId,
});
let result: { id: string; channel_id: string };
try {
result = (await request(
@@ -135,7 +154,7 @@ export async function sendDiscordComponentMessage(
channelId,
rest,
token,
hasMedia: Boolean(files?.length),
hasMedia: Boolean(opts.mediaUrl),
});
}
@@ -156,3 +175,56 @@ export async function sendDiscordComponentMessage(
channelId: result.channel_id ?? channelId,
};
}
export async function editDiscordComponentMessage(
to: string,
messageId: string,
spec: DiscordComponentMessageSpec,
opts: DiscordComponentSendOpts = {},
): Promise<DiscordSendResult> {
const cfg = opts.cfg ?? loadConfig();
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId, cfg);
const { channelId } = await resolveChannelId(rest, recipient, request);
const { body, buildResult } = await buildDiscordComponentPayload({
spec,
opts,
accountId: accountInfo.accountId,
});
let result: { id: string; channel_id: string };
try {
result = (await request(
() =>
rest.patch(Routes.channelMessage(channelId, messageId), {
body,
}) as Promise<{ id: string; channel_id: string }>,
"components",
)) as { id: string; channel_id: string };
} catch (err) {
throw await buildDiscordSendError(err, {
channelId,
rest,
token,
hasMedia: Boolean(opts.mediaUrl),
});
}
registerDiscordComponentEntries({
entries: buildResult.entries,
modals: buildResult.modals,
messageId: result.id ?? messageId,
});
recordChannelActivity({
channel: "discord",
accountId: accountInfo.accountId,
direction: "outbound",
});
return {
messageId: result.id ?? messageId,
channelId: result.channel_id ?? channelId,
};
}

View File

@@ -44,7 +44,7 @@ export {
sendWebhookMessageDiscord,
sendVoiceMessageDiscord,
} from "./send.outbound.js";
export { sendDiscordComponentMessage } from "./send.components.js";
export { editDiscordComponentMessage, sendDiscordComponentMessage } from "./send.components.js";
export { sendTypingDiscord } from "./send.typing.js";
export {
fetchChannelPermissionsDiscord,

View File

@@ -108,6 +108,7 @@ export {
createScheduledEventDiscord,
createThreadDiscord,
deleteChannelDiscord,
editDiscordComponentMessage,
deleteMessageDiscord,
editChannelDiscord,
editMessageDiscord,

View File

@@ -221,6 +221,7 @@ describe("plugin-sdk subpath exports", () => {
it("exports Discord component helpers from the dedicated subpath", async () => {
const discordSdk = await import("openclaw/plugin-sdk/discord");
expect(typeof discordSdk.buildDiscordComponentMessage).toBe("function");
expect(typeof discordSdk.editDiscordComponentMessage).toBe("function");
expect(typeof discordSdk.resolveDiscordAccount).toBe("function");
});