mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-29 10:02:04 +00:00
Discord: stop deferring component picker clicks
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -108,6 +108,7 @@ export {
|
||||
createScheduledEventDiscord,
|
||||
createThreadDiscord,
|
||||
deleteChannelDiscord,
|
||||
editDiscordComponentMessage,
|
||||
deleteMessageDiscord,
|
||||
editChannelDiscord,
|
||||
editMessageDiscord,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user