Discord: add component v2 UI tool support (#17419)

This commit is contained in:
Shadow
2026-02-15 21:19:25 -06:00
committed by GitHub
parent b4a9eacd76
commit a61c2dc4bd
15 changed files with 2893 additions and 43 deletions

View File

@@ -0,0 +1,89 @@
import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js";
const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000;
const componentEntries = new Map<string, DiscordComponentEntry>();
const modalEntries = new Map<string, DiscordModalEntry>();
function isExpired(entry: { expiresAt?: number }, now: number) {
return typeof entry.expiresAt === "number" && entry.expiresAt <= now;
}
function normalizeEntryTimestamps<T extends { createdAt?: number; expiresAt?: number }>(
entry: T,
now: number,
ttlMs: number,
): T {
const createdAt = entry.createdAt ?? now;
const expiresAt = entry.expiresAt ?? createdAt + ttlMs;
return { ...entry, createdAt, expiresAt };
}
export function registerDiscordComponentEntries(params: {
entries: DiscordComponentEntry[];
modals: DiscordModalEntry[];
ttlMs?: number;
messageId?: string;
}): void {
const now = Date.now();
const ttlMs = params.ttlMs ?? DEFAULT_COMPONENT_TTL_MS;
for (const entry of params.entries) {
const normalized = normalizeEntryTimestamps(
{ ...entry, messageId: params.messageId ?? entry.messageId },
now,
ttlMs,
);
componentEntries.set(entry.id, normalized);
}
for (const modal of params.modals) {
const normalized = normalizeEntryTimestamps(
{ ...modal, messageId: params.messageId ?? modal.messageId },
now,
ttlMs,
);
modalEntries.set(modal.id, normalized);
}
}
export function resolveDiscordComponentEntry(params: {
id: string;
consume?: boolean;
}): DiscordComponentEntry | null {
const entry = componentEntries.get(params.id);
if (!entry) {
return null;
}
const now = Date.now();
if (isExpired(entry, now)) {
componentEntries.delete(params.id);
return null;
}
if (params.consume !== false) {
componentEntries.delete(params.id);
}
return entry;
}
export function resolveDiscordModalEntry(params: {
id: string;
consume?: boolean;
}): DiscordModalEntry | null {
const entry = modalEntries.get(params.id);
if (!entry) {
return null;
}
const now = Date.now();
if (isExpired(entry, now)) {
modalEntries.delete(params.id);
return null;
}
if (params.consume !== false) {
modalEntries.delete(params.id);
}
return entry;
}
export function clearDiscordComponentEntries(): void {
componentEntries.clear();
modalEntries.clear();
}

View File

@@ -0,0 +1,98 @@
import { MessageFlags } from "discord-api-types/v10";
import { describe, expect, it, beforeEach } from "vitest";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
resolveDiscordComponentEntry,
resolveDiscordModalEntry,
} from "./components-registry.js";
import {
buildDiscordComponentMessage,
buildDiscordComponentMessageFlags,
readDiscordComponentSpec,
} from "./components.js";
describe("discord components", () => {
it("builds v2 containers with modal trigger", () => {
const spec = readDiscordComponentSpec({
text: "Choose a path",
blocks: [
{
type: "actions",
buttons: [{ label: "Approve", style: "success" }],
},
],
modal: {
title: "Details",
fields: [{ type: "text", label: "Requester" }],
},
});
if (!spec) {
throw new Error("Expected component spec to be parsed");
}
const result = buildDiscordComponentMessage({ spec });
expect(result.components).toHaveLength(1);
expect(result.components[0]?.isV2).toBe(true);
expect(buildDiscordComponentMessageFlags(result.components)).toBe(MessageFlags.IsComponentsV2);
expect(result.modals).toHaveLength(1);
const trigger = result.entries.find((entry) => entry.kind === "modal-trigger");
expect(trigger?.modalId).toBe(result.modals[0]?.id);
});
it("requires options for modal select fields", () => {
expect(() =>
readDiscordComponentSpec({
modal: {
title: "Details",
fields: [{ type: "select", label: "Priority" }],
},
}),
).toThrow("options");
});
it("requires attachment references for file blocks", () => {
expect(() =>
readDiscordComponentSpec({
blocks: [{ type: "file", file: "https://example.com/report.pdf" }],
}),
).toThrow("attachment://");
expect(() =>
readDiscordComponentSpec({
blocks: [{ type: "file", file: "attachment://" }],
}),
).toThrow("filename");
});
});
describe("discord component registry", () => {
beforeEach(() => {
clearDiscordComponentEntries();
});
it("registers and consumes component entries", () => {
registerDiscordComponentEntries({
entries: [{ id: "btn_1", kind: "button", label: "Confirm" }],
modals: [
{
id: "mdl_1",
title: "Details",
fields: [{ id: "fld_1", name: "name", label: "Name", type: "text" }],
},
],
messageId: "msg_1",
ttlMs: 1000,
});
const entry = resolveDiscordComponentEntry({ id: "btn_1", consume: false });
expect(entry?.messageId).toBe("msg_1");
const modal = resolveDiscordModalEntry({ id: "mdl_1", consume: false });
expect(modal?.messageId).toBe("msg_1");
const consumed = resolveDiscordComponentEntry({ id: "btn_1" });
expect(consumed?.id).toBe("btn_1");
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
});
});

1120
src/discord/components.ts Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,28 @@
import type { ButtonInteraction, ComponentData, StringSelectMenuInteraction } from "@buape/carbon";
import type {
ButtonInteraction,
ComponentData,
ModalInteraction,
StringSelectMenuInteraction,
} from "@buape/carbon";
import type { Client } from "@buape/carbon";
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { DiscordAccountConfig } from "../../config/types.discord.js";
import type { DiscordChannelConfigResolved } from "./allow-list.js";
import { buildAgentSessionKey } from "../../routing/resolve-route.js";
import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js";
import {
clearDiscordComponentEntries,
registerDiscordComponentEntries,
resolveDiscordComponentEntry,
resolveDiscordModalEntry,
} from "../components-registry.js";
import {
createAgentComponentButton,
createAgentSelectMenu,
createDiscordComponentButton,
createDiscordComponentModal,
} from "./agent-components.js";
import {
resolveDiscordMemberAllowed,
resolveDiscordOwnerAllowFrom,
@@ -29,6 +46,12 @@ import {
const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
const dispatchReplyMock = vi.hoisted(() => vi.fn());
const deliverDiscordReplyMock = vi.hoisted(() => vi.fn());
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
const resolveStorePathMock = vi.hoisted(() => vi.fn());
let lastDispatchCtx: Record<string, unknown> | undefined;
vi.mock("../../pairing/pairing-store.js", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
@@ -43,6 +66,27 @@ vi.mock("../../infra/system-events.js", async (importOriginal) => {
};
});
vi.mock("../../auto-reply/reply/provider-dispatcher.js", () => ({
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
}));
vi.mock("./reply-delivery.js", () => ({
deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args),
}));
vi.mock("../../channels/session.js", () => ({
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
}));
vi.mock("../../config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/sessions.js")>();
return {
...actual,
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
};
});
describe("agent components", () => {
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
@@ -128,6 +172,167 @@ describe("agent components", () => {
});
});
describe("discord component interactions", () => {
const createCfg = (): OpenClawConfig =>
({
channels: {
discord: {
replyToMode: "first",
},
},
}) as OpenClawConfig;
const createDiscordConfig = (overrides?: Partial<DiscordAccountConfig>): DiscordAccountConfig =>
({
replyToMode: "first",
...overrides,
}) as DiscordAccountConfig;
type DispatchParams = {
ctx: Record<string, unknown>;
dispatcherOptions: {
deliver: (payload: { text?: string }) => Promise<void> | void;
};
};
const createComponentContext = (
overrides?: Partial<Parameters<typeof createDiscordComponentButton>[0]>,
) =>
({
cfg: createCfg(),
accountId: "default",
dmPolicy: "allowlist",
allowFrom: ["123456789"],
discordConfig: createDiscordConfig(),
token: "token",
...overrides,
}) as Parameters<typeof createDiscordComponentButton>[0];
const createComponentButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const defer = vi.fn().mockResolvedValue(undefined);
const interaction = {
rawData: { channel_id: "dm-channel", id: "interaction-1" },
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
customId: "occomp:cid=btn_1",
message: { id: "msg-1" },
client: { rest: {} },
defer,
reply,
...overrides,
} as unknown as ButtonInteraction;
return { interaction, defer, reply };
};
const createModalInteraction = (overrides: Partial<ModalInteraction> = {}) => {
const reply = vi.fn().mockResolvedValue(undefined);
const acknowledge = vi.fn().mockResolvedValue(undefined);
const fields = {
getText: (key: string) => (key === "fld_1" ? "Casey" : undefined),
getStringSelect: (_key: string) => undefined,
getRoleSelect: (_key: string) => [],
getUserSelect: (_key: string) => [],
};
const interaction = {
rawData: { channel_id: "dm-channel", id: "interaction-2" },
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
customId: "ocmodal:mid=mdl_1",
fields,
acknowledge,
reply,
client: { rest: {} },
...overrides,
} as unknown as ModalInteraction;
return { interaction, acknowledge, reply };
};
beforeEach(() => {
clearDiscordComponentEntries();
lastDispatchCtx = undefined;
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
enqueueSystemEventMock.mockReset();
dispatchReplyMock.mockReset().mockImplementation(async (params: DispatchParams) => {
lastDispatchCtx = params.ctx;
await params.dispatcherOptions.deliver({ text: "ok" });
});
deliverDiscordReplyMock.mockReset();
recordInboundSessionMock.mockReset().mockResolvedValue(undefined);
readSessionUpdatedAtMock.mockReset().mockReturnValue(undefined);
resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions-test.json");
});
it("routes button clicks with reply references", async () => {
registerDiscordComponentEntries({
entries: [
{
id: "btn_1",
kind: "button",
label: "Approve",
messageId: "msg-1",
sessionKey: "session-1",
agentId: "agent-1",
accountId: "default",
},
],
modals: [],
});
const button = createDiscordComponentButton(createComponentContext());
const { interaction, reply } = createComponentButtonInteraction();
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-1");
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
});
it("routes modal submissions with field values", async () => {
registerDiscordComponentEntries({
entries: [],
modals: [
{
id: "mdl_1",
title: "Details",
messageId: "msg-2",
sessionKey: "session-2",
agentId: "agent-2",
accountId: "default",
fields: [
{
id: "fld_1",
name: "name",
label: "Name",
type: "text",
},
],
},
],
});
const modal = createDiscordComponentModal(
createComponentContext({
discordConfig: createDiscordConfig({ replyToMode: "all" }),
}),
);
const { interaction, acknowledge } = createModalInteraction();
await modal.run(interaction, { mid: "mdl_1" } as ComponentData);
expect(acknowledge).toHaveBeenCalledTimes(1);
expect(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.');
expect(lastDispatchCtx?.BodyForAgent).toContain("- Name: Casey");
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2");
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
});
});
describe("resolveDiscordOwnerAllowFrom", () => {
it("returns undefined when no allowlist is configured", () => {
const result = resolveDiscordOwnerAllowFrom({

View File

@@ -1,5 +1,10 @@
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { Client, ReadyListener, type BaseMessageInteractiveComponent } from "@buape/carbon";
import {
Client,
ReadyListener,
type BaseMessageInteractiveComponent,
type Modal,
} from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
import { inspect } from "node:util";
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
@@ -33,7 +38,17 @@ import { fetchDiscordApplicationId } from "../probe.js";
import { resolveDiscordChannelAllowlist } from "../resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../resolve-users.js";
import { normalizeDiscordToken } from "../token.js";
import { createAgentComponentButton, createAgentSelectMenu } from "./agent-components.js";
import {
createAgentComponentButton,
createAgentSelectMenu,
createDiscordComponentButton,
createDiscordComponentChannelSelect,
createDiscordComponentMentionableSelect,
createDiscordComponentModal,
createDiscordComponentRoleSelect,
createDiscordComponentStringSelect,
createDiscordComponentUserSelect,
} from "./agent-components.js";
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
import { createDiscordGatewayPlugin } from "./gateway-plugin.js";
import { registerGateway, unregisterGateway } from "./gateway-registry.js";
@@ -432,30 +447,32 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
sessionPrefix,
}),
];
const modals: Modal[] = [];
if (execApprovalsHandler) {
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
}
if (agentComponentsEnabled) {
components.push(
createAgentComponentButton({
cfg,
accountId: account.accountId,
guildEntries,
allowFrom,
dmPolicy,
}),
);
components.push(
createAgentSelectMenu({
cfg,
accountId: account.accountId,
guildEntries,
allowFrom,
dmPolicy,
}),
);
const componentContext = {
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
guildEntries,
allowFrom,
dmPolicy,
runtime,
token,
};
components.push(createAgentComponentButton(componentContext));
components.push(createAgentSelectMenu(componentContext));
components.push(createDiscordComponentButton(componentContext));
components.push(createDiscordComponentStringSelect(componentContext));
components.push(createDiscordComponentUserSelect(componentContext));
components.push(createDiscordComponentRoleSelect(componentContext));
components.push(createDiscordComponentMentionableSelect(componentContext));
components.push(createDiscordComponentChannelSelect(componentContext));
modals.push(createDiscordComponentModal(componentContext));
}
class DiscordStatusReadyListener extends ReadyListener {
@@ -487,6 +504,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
commands,
listeners: [new DiscordStatusReadyListener()],
components,
modals,
},
[createDiscordGatewayPlugin({ discordConfig: discordCfg, runtime })],
);

View File

@@ -0,0 +1,169 @@
import type { APIChannel } from "discord-api-types/v10";
import {
serializePayload,
type MessagePayloadFile,
type MessagePayloadObject,
type RequestClient,
} from "@buape/carbon";
import { ChannelType, Routes } from "discord-api-types/v10";
import type { DiscordSendResult } from "./send.types.js";
import { loadConfig } from "../config/config.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { loadWebMedia } from "../web/media.js";
import { resolveDiscordAccount } from "./accounts.js";
import { registerDiscordComponentEntries } from "./components-registry.js";
import {
buildDiscordComponentMessage,
buildDiscordComponentMessageFlags,
resolveDiscordComponentAttachmentName,
type DiscordComponentMessageSpec,
} from "./components.js";
import {
buildDiscordSendError,
createDiscordClient,
parseAndResolveRecipient,
resolveChannelId,
stripUndefinedFields,
SUPPRESS_NOTIFICATIONS_FLAG,
} from "./send.shared.js";
const DISCORD_FORUM_LIKE_TYPES = new Set<number>([ChannelType.GuildForum, ChannelType.GuildMedia]);
function extractComponentAttachmentNames(spec: DiscordComponentMessageSpec): string[] {
const names: string[] = [];
for (const block of spec.blocks ?? []) {
if (block.type === "file") {
names.push(resolveDiscordComponentAttachmentName(block.file));
}
}
return names;
}
type DiscordComponentSendOpts = {
accountId?: string;
token?: string;
rest?: RequestClient;
silent?: boolean;
replyTo?: string;
sessionKey?: string;
agentId?: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
filename?: string;
};
export async function sendDiscordComponentMessage(
to: string,
spec: DiscordComponentMessageSpec,
opts: DiscordComponentSendOpts = {},
): Promise<DiscordSendResult> {
const cfg = loadConfig();
const accountInfo = resolveDiscordAccount({ cfg, accountId: opts.accountId });
const { token, rest, request } = createDiscordClient(opts, cfg);
const recipient = await parseAndResolveRecipient(to, opts.accountId);
const { channelId } = await resolveChannelId(rest, recipient, request);
let channelType: number | undefined;
try {
const channel = (await rest.get(Routes.channel(channelId))) as APIChannel | undefined;
channelType = channel?.type;
} catch {
channelType = undefined;
}
if (channelType && DISCORD_FORUM_LIKE_TYPES.has(channelType)) {
throw new Error("Discord components are not supported in forum-style channels");
}
const buildResult = buildDiscordComponentMessage({
spec,
sessionKey: opts.sessionKey,
agentId: opts.agentId,
accountId: accountInfo.accountId,
});
const flags = buildDiscordComponentMessageFlags(buildResult.components);
const finalFlags = opts.silent
? (flags ?? 0) | SUPPRESS_NOTIFICATIONS_FLAG
: (flags ?? undefined);
const messageReference = opts.replyTo
? { message_id: opts.replyTo, fail_if_not_exists: false }
: undefined;
const attachmentNames = extractComponentAttachmentNames(spec);
const uniqueAttachmentNames = [...new Set(attachmentNames)];
if (uniqueAttachmentNames.length > 1) {
throw new Error(
"Discord component attachments currently support a single file. Use media-gallery for multiple files.",
);
}
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();
const fileName = filenameOverride || media.fileName || "upload";
if (expectedAttachmentName && expectedAttachmentName !== fileName) {
throw new Error(
`Component file block expects attachment "${expectedAttachmentName}", but the uploaded file is "${fileName}". Update components.blocks[].file or provide a matching filename.`,
);
}
let fileData: Blob;
if (media.buffer instanceof Blob) {
fileData = media.buffer;
} else {
const arrayBuffer = new ArrayBuffer(media.buffer.byteLength);
new Uint8Array(arrayBuffer).set(media.buffer);
fileData = new Blob([arrayBuffer]);
}
files = [{ data: fileData, name: fileName }];
} else if (expectedAttachmentName) {
throw new Error(
"Discord component file blocks require a media attachment (media/path/filePath).",
);
}
const payload: MessagePayloadObject = {
components: buildResult.components,
...(finalFlags ? { flags: finalFlags } : {}),
...(files ? { files } : {}),
};
const body = stripUndefinedFields({
...serializePayload(payload),
...(messageReference ? { message_reference: messageReference } : {}),
});
let result: { id: string; channel_id: string };
try {
result = (await request(
() =>
rest.post(Routes.channelMessages(channelId), {
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(files?.length),
});
}
registerDiscordComponentEntries({
entries: buildResult.entries,
modals: buildResult.modals,
messageId: result.id,
});
recordChannelActivity({
channel: "discord",
accountId: accountInfo.accountId,
direction: "outbound",
});
return {
messageId: result.id ?? "unknown",
channelId: result.channel_id ?? channelId,
};
}

View File

@@ -43,6 +43,7 @@ export {
sendStickerDiscord,
sendVoiceMessageDiscord,
} from "./send.outbound.js";
export { sendDiscordComponentMessage } from "./send.components.js";
export {
fetchChannelPermissionsDiscord,
fetchReactionsDiscord,