From a61c2dc4bd69edca3534c8ec3fe2fedd68473b28 Mon Sep 17 00:00:00 2001 From: Shadow Date: Sun, 15 Feb 2026 21:19:25 -0600 Subject: [PATCH] Discord: add component v2 UI tool support (#17419) --- CHANGELOG.md | 1 + docs/channels/discord.md | 71 ++ extensions/discord/src/channel.ts | 6 + src/agents/tools/discord-actions-messaging.ts | 44 +- src/agents/tools/message-tool.ts | 94 ++ .../plugins/actions/discord/handle-action.ts | 16 +- src/discord/components-registry.ts | 89 ++ src/discord/components.test.ts | 98 ++ src/discord/components.ts | 1120 +++++++++++++++++ src/discord/monitor/agent-components.ts | 949 +++++++++++++- src/discord/monitor/monitor.test.ts | 209 ++- src/discord/monitor/provider.ts | 58 +- src/discord/send.components.ts | 169 +++ src/discord/send.ts | 1 + src/infra/outbound/message-action-runner.ts | 11 +- 15 files changed, 2893 insertions(+), 43 deletions(-) create mode 100644 src/discord/components-registry.ts create mode 100644 src/discord/components.test.ts create mode 100644 src/discord/components.ts create mode 100644 src/discord/send.components.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 68a34e69b3..11c3ed0628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai ### Changes - Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204. +- Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow. - Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread. - Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x. - Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow. diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 4942797231..e232851454 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -87,6 +87,77 @@ Token resolution is account-aware. Config token values win over env fallback. `D - Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`). - Native slash commands run in isolated command sessions (`agent::discord:slash:`), while still carrying `CommandTargetSessionKey` to the routed conversation session. +## Interactive components + +OpenClaw supports Discord components v2 containers for agent messages. Use the message tool with a `components` payload. Interaction results are routed back to the agent as normal inbound messages and follow the existing Discord `replyToMode` settings. + +Supported blocks: + +- `text`, `section`, `separator`, `actions`, `media-gallery`, `file` +- Action rows allow up to 5 buttons or a single select menu +- Select types: `string`, `user`, `role`, `mentionable`, `channel` + +File attachments: + +- `file` blocks must point to an attachment reference (`attachment://`) +- Provide the attachment via `media`/`path`/`filePath` (single file); use `media-gallery` for multiple files +- Use `filename` to override the upload name when it should match the attachment reference + +Modal forms: + +- Add `components.modal` with up to 5 fields +- Field types: `text`, `checkbox`, `radio`, `select`, `role-select`, `user-select` +- OpenClaw adds a trigger button automatically + +Example: + +```json5 +{ + channel: "discord", + action: "send", + to: "channel:123456789012345678", + message: "Optional fallback text", + components: { + text: "Choose a path", + blocks: [ + { + type: "actions", + buttons: [ + { label: "Approve", style: "success" }, + { label: "Decline", style: "danger" }, + ], + }, + { + type: "actions", + select: { + type: "string", + placeholder: "Pick an option", + options: [ + { label: "Option A", value: "a" }, + { label: "Option B", value: "b" }, + ], + }, + }, + ], + modal: { + title: "Details", + triggerLabel: "Open form", + fields: [ + { type: "text", label: "Requester" }, + { + type: "select", + label: "Priority", + options: [ + { label: "Low", value: "low" }, + { label: "High", value: "high" }, + ], + }, + ], + }, + }, +} +``` + ## Access control and routing diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 4119a95e81..f8fc9576e6 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -158,6 +158,12 @@ export const discordPlugin: ChannelPlugin = { threading: { resolveReplyToMode: ({ cfg }) => cfg.channels?.discord?.replyToMode ?? "off", }, + agentPrompt: { + messageToolHints: () => [ + "- Discord components: set `components` when sending messages to include buttons, selects, or v2 containers.", + "- Forms: add `components.modal` (title, fields). OpenClaw adds a trigger button and routes submissions as new messages.", + ], + }, messaging: { normalizeTarget: normalizeDiscordMessagingTarget, targetResolver: { diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 1097d48a00..a29f4b6623 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -1,6 +1,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import type { DiscordActionConfig } from "../../config/config.js"; import type { DiscordSendComponents, DiscordSendEmbeds } from "../../discord/send.shared.js"; +import { readDiscordComponentSpec } from "../../discord/components.js"; import { createThreadDiscord, deleteMessageDiscord, @@ -16,6 +17,7 @@ import { removeOwnReactionsDiscord, removeReactionDiscord, searchMessagesDiscord, + sendDiscordComponentMessage, sendMessageDiscord, sendPollDiscord, sendStickerDiscord, @@ -233,24 +235,54 @@ export async function handleDiscordMessagingAction( const to = readStringParam(params, "to", { required: true }); const asVoice = params.asVoice === true; const silent = params.silent === true; + const rawComponents = params.components; + const componentSpec = + rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents) + ? readDiscordComponentSpec(rawComponents) + : null; + const components: DiscordSendComponents | undefined = + Array.isArray(rawComponents) || typeof rawComponents === "function" + ? (rawComponents as DiscordSendComponents) + : undefined; const content = readStringParam(params, "content", { - required: !asVoice, + required: !asVoice && !componentSpec && !components, allowEmpty: true, }); const mediaUrl = readStringParam(params, "mediaUrl", { trim: false }) ?? readStringParam(params, "path", { trim: false }) ?? readStringParam(params, "filePath", { trim: false }); + const filename = readStringParam(params, "filename"); const replyTo = readStringParam(params, "replyTo"); - const rawComponents = params.components; - const components: DiscordSendComponents | undefined = - Array.isArray(rawComponents) || typeof rawComponents === "function" - ? (rawComponents as DiscordSendComponents) - : undefined; const rawEmbeds = params.embeds; const embeds: DiscordSendEmbeds | undefined = Array.isArray(rawEmbeds) ? (rawEmbeds as DiscordSendEmbeds) : undefined; + const sessionKey = readStringParam(params, "__sessionKey"); + const agentId = readStringParam(params, "__agentId"); + + if (componentSpec) { + if (asVoice) { + throw new Error("Discord components cannot be sent as voice messages."); + } + if (embeds?.length) { + throw new Error("Discord components cannot include embeds."); + } + const normalizedContent = content?.trim() ? content : undefined; + const payload = componentSpec.text + ? componentSpec + : { ...componentSpec, text: normalizedContent }; + const result = await sendDiscordComponentMessage(to, payload, { + ...(accountId ? { accountId } : {}), + silent, + replyTo: replyTo ?? undefined, + sessionKey: sessionKey ?? undefined, + agentId: agentId ?? undefined, + mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, + }); + return jsonResult({ ok: true, result, components: true }); + } // Handle voice message sending if (asVoice) { diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index c30b89d489..e894f71cf0 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -47,6 +47,98 @@ function buildRoutingSchema() { }; } +const discordComponentEmojiSchema = Type.Object({ + name: Type.String(), + id: Type.Optional(Type.String()), + animated: Type.Optional(Type.Boolean()), +}); + +const discordComponentOptionSchema = Type.Object({ + label: Type.String(), + value: Type.String(), + description: Type.Optional(Type.String()), + emoji: Type.Optional(discordComponentEmojiSchema), + default: Type.Optional(Type.Boolean()), +}); + +const discordComponentButtonSchema = Type.Object({ + label: Type.String(), + style: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), + url: Type.Optional(Type.String()), + emoji: Type.Optional(discordComponentEmojiSchema), + disabled: Type.Optional(Type.Boolean()), +}); + +const discordComponentSelectSchema = Type.Object({ + type: Type.Optional(stringEnum(["string", "user", "role", "mentionable", "channel"])), + placeholder: Type.Optional(Type.String()), + minValues: Type.Optional(Type.Number()), + maxValues: Type.Optional(Type.Number()), + options: Type.Optional(Type.Array(discordComponentOptionSchema)), +}); + +const discordComponentBlockSchema = Type.Object({ + type: Type.String(), + text: Type.Optional(Type.String()), + texts: Type.Optional(Type.Array(Type.String())), + accessory: Type.Optional( + Type.Object({ + type: Type.String(), + url: Type.Optional(Type.String()), + button: Type.Optional(discordComponentButtonSchema), + }), + ), + spacing: Type.Optional(stringEnum(["small", "large"])), + divider: Type.Optional(Type.Boolean()), + buttons: Type.Optional(Type.Array(discordComponentButtonSchema)), + select: Type.Optional(discordComponentSelectSchema), + items: Type.Optional( + Type.Array( + Type.Object({ + url: Type.String(), + description: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), + }), + ), + ), + file: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), +}); + +const discordComponentModalFieldSchema = Type.Object({ + type: Type.String(), + name: Type.Optional(Type.String()), + label: Type.String(), + description: Type.Optional(Type.String()), + placeholder: Type.Optional(Type.String()), + required: Type.Optional(Type.Boolean()), + options: Type.Optional(Type.Array(discordComponentOptionSchema)), + minValues: Type.Optional(Type.Number()), + maxValues: Type.Optional(Type.Number()), + minLength: Type.Optional(Type.Number()), + maxLength: Type.Optional(Type.Number()), + style: Type.Optional(stringEnum(["short", "paragraph"])), +}); + +const discordComponentModalSchema = Type.Object({ + title: Type.String(), + triggerLabel: Type.Optional(Type.String()), + triggerStyle: Type.Optional(stringEnum(["primary", "secondary", "success", "danger", "link"])), + fields: Type.Array(discordComponentModalFieldSchema), +}); + +const discordComponentMessageSchema = Type.Object({ + text: Type.Optional(Type.String()), + container: Type.Optional( + Type.Object({ + accentColor: Type.Optional(Type.String()), + spoiler: Type.Optional(Type.Boolean()), + }), + ), + blocks: Type.Optional(Type.Array(discordComponentBlockSchema)), + modal: Type.Optional(discordComponentModalSchema), +}); + function buildSendSchema(options: { includeButtons: boolean; includeCards: boolean }) { const props: Record = { message: Type.Optional(Type.String()), @@ -105,6 +197,7 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole }, ), ), + components: Type.Optional(discordComponentMessageSchema), }; if (!options.includeButtons) { delete props.buttons; @@ -481,6 +574,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { defaultAccountId: accountId ?? undefined, gateway, toolContext, + sessionKey: options?.agentSessionKey, agentId: options?.agentSessionKey ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) : undefined, diff --git a/src/channels/plugins/actions/discord/handle-action.ts b/src/channels/plugins/actions/discord/handle-action.ts index 3a5f2baa35..6c867d81c8 100644 --- a/src/channels/plugins/actions/discord/handle-action.ts +++ b/src/channels/plugins/actions/discord/handle-action.ts @@ -36,12 +36,12 @@ export async function handleDiscordMessageAction( const to = readStringParam(params, "to", { required: true }); const asVoice = params.asVoice === true; const rawComponents = params.components; - const components = - rawComponents && (Array.isArray(rawComponents) || typeof rawComponents === "function") - ? rawComponents - : undefined; + const hasComponents = + Boolean(rawComponents) && + (typeof rawComponents === "function" || typeof rawComponents === "object"); + const components = hasComponents ? rawComponents : undefined; const content = readStringParam(params, "message", { - required: !asVoice, + required: !asVoice && !hasComponents, allowEmpty: true, }); // Support media, path, and filePath for media URL @@ -49,10 +49,13 @@ export async function handleDiscordMessageAction( readStringParam(params, "media", { trim: false }) ?? readStringParam(params, "path", { trim: false }) ?? readStringParam(params, "filePath", { trim: false }); + const filename = readStringParam(params, "filename"); const replyTo = readStringParam(params, "replyTo"); const rawEmbeds = params.embeds; const embeds = Array.isArray(rawEmbeds) ? rawEmbeds : undefined; const silent = params.silent === true; + const sessionKey = readStringParam(params, "__sessionKey"); + const agentId = readStringParam(params, "__agentId"); return await handleDiscordAction( { action: "sendMessage", @@ -60,11 +63,14 @@ export async function handleDiscordMessageAction( to, content, mediaUrl: mediaUrl ?? undefined, + filename: filename ?? undefined, replyTo: replyTo ?? undefined, components, embeds, asVoice, silent, + __sessionKey: sessionKey ?? undefined, + __agentId: agentId ?? undefined, }, cfg, ); diff --git a/src/discord/components-registry.ts b/src/discord/components-registry.ts new file mode 100644 index 0000000000..ce7014aba7 --- /dev/null +++ b/src/discord/components-registry.ts @@ -0,0 +1,89 @@ +import type { DiscordComponentEntry, DiscordModalEntry } from "./components.js"; + +const DEFAULT_COMPONENT_TTL_MS = 30 * 60 * 1000; + +const componentEntries = new Map(); +const modalEntries = new Map(); + +function isExpired(entry: { expiresAt?: number }, now: number) { + return typeof entry.expiresAt === "number" && entry.expiresAt <= now; +} + +function normalizeEntryTimestamps( + 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(); +} diff --git a/src/discord/components.test.ts b/src/discord/components.test.ts new file mode 100644 index 0000000000..9a49af7b46 --- /dev/null +++ b/src/discord/components.test.ts @@ -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(); + }); +}); diff --git a/src/discord/components.ts b/src/discord/components.ts new file mode 100644 index 0000000000..b45c28b210 --- /dev/null +++ b/src/discord/components.ts @@ -0,0 +1,1120 @@ +import { + Button, + ChannelSelectMenu, + CheckboxGroup, + Container, + File, + Label, + LinkButton, + MediaGallery, + MentionableSelectMenu, + Modal, + RadioGroup, + RoleSelectMenu, + Row, + Section, + Separator, + StringSelectMenu, + TextDisplay, + TextInput, + Thumbnail, + UserSelectMenu, + parseCustomId, + type ComponentParserResult, + type TopLevelComponents, +} from "@buape/carbon"; +import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10"; +import crypto from "node:crypto"; + +export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp"; +export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal"; +export const DISCORD_COMPONENT_ATTACHMENT_PREFIX = "attachment://"; + +export type DiscordComponentButtonStyle = "primary" | "secondary" | "success" | "danger" | "link"; + +export type DiscordComponentSelectType = "string" | "user" | "role" | "mentionable" | "channel"; + +export type DiscordComponentModalFieldType = + | "text" + | "checkbox" + | "radio" + | "select" + | "role-select" + | "user-select"; + +export type DiscordComponentButtonSpec = { + label: string; + style?: DiscordComponentButtonStyle; + url?: string; + emoji?: { + name: string; + id?: string; + animated?: boolean; + }; + disabled?: boolean; +}; + +export type DiscordComponentSelectOption = { + label: string; + value: string; + description?: string; + emoji?: { + name: string; + id?: string; + animated?: boolean; + }; + default?: boolean; +}; + +export type DiscordComponentSelectSpec = { + type?: DiscordComponentSelectType; + placeholder?: string; + minValues?: number; + maxValues?: number; + options?: DiscordComponentSelectOption[]; +}; + +export type DiscordComponentSectionAccessory = + | { + type: "thumbnail"; + url: string; + } + | { + type: "button"; + button: DiscordComponentButtonSpec; + }; + +type DiscordComponentSeparatorSpacing = "small" | "large" | 1 | 2; + +export type DiscordComponentBlock = + | { + type: "text"; + text: string; + } + | { + type: "section"; + text?: string; + texts?: string[]; + accessory?: DiscordComponentSectionAccessory; + } + | { + type: "separator"; + spacing?: DiscordComponentSeparatorSpacing; + divider?: boolean; + } + | { + type: "actions"; + buttons?: DiscordComponentButtonSpec[]; + select?: DiscordComponentSelectSpec; + } + | { + type: "media-gallery"; + items: Array<{ url: string; description?: string; spoiler?: boolean }>; + } + | { + type: "file"; + file: `attachment://${string}`; + spoiler?: boolean; + }; + +export type DiscordModalFieldSpec = { + type: DiscordComponentModalFieldType; + name?: string; + label: string; + description?: string; + placeholder?: string; + required?: boolean; + options?: DiscordComponentSelectOption[]; + minValues?: number; + maxValues?: number; + minLength?: number; + maxLength?: number; + style?: "short" | "paragraph"; +}; + +export type DiscordModalSpec = { + title: string; + triggerLabel?: string; + triggerStyle?: DiscordComponentButtonStyle; + fields: DiscordModalFieldSpec[]; +}; + +export type DiscordComponentMessageSpec = { + text?: string; + container?: { + accentColor?: string | number; + spoiler?: boolean; + }; + blocks?: DiscordComponentBlock[]; + modal?: DiscordModalSpec; +}; + +export type DiscordComponentEntry = { + id: string; + kind: "button" | "select" | "modal-trigger"; + label: string; + selectType?: DiscordComponentSelectType; + options?: Array<{ value: string; label: string }>; + modalId?: string; + sessionKey?: string; + agentId?: string; + accountId?: string; + messageId?: string; + createdAt?: number; + expiresAt?: number; +}; + +export type DiscordModalFieldDefinition = { + id: string; + name: string; + label: string; + type: DiscordComponentModalFieldType; + description?: string; + placeholder?: string; + required?: boolean; + options?: DiscordComponentSelectOption[]; + minValues?: number; + maxValues?: number; + minLength?: number; + maxLength?: number; + style?: "short" | "paragraph"; +}; + +export type DiscordModalEntry = { + id: string; + title: string; + fields: DiscordModalFieldDefinition[]; + sessionKey?: string; + agentId?: string; + accountId?: string; + messageId?: string; + createdAt?: number; + expiresAt?: number; +}; + +export type DiscordComponentBuildResult = { + components: TopLevelComponents[]; + entries: DiscordComponentEntry[]; + modals: DiscordModalEntry[]; +}; + +const BLOCK_ALIASES = new Map([ + ["row", "actions"], + ["action-row", "actions"], +]); + +function createShortId(prefix: string) { + return `${prefix}${crypto.randomBytes(6).toString("base64url")}`; +} + +function requireObject(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function readString(value: unknown, label: string, opts?: { allowEmpty?: boolean }): string { + if (typeof value !== "string") { + throw new Error(`${label} must be a string`); + } + const trimmed = value.trim(); + if (!opts?.allowEmpty && !trimmed) { + throw new Error(`${label} cannot be empty`); + } + return opts?.allowEmpty ? value : trimmed; +} + +function readOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function readOptionalNumber(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + return value; +} + +function normalizeModalFieldName(value: string | undefined, index: number) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + return `field_${index + 1}`; +} + +function normalizeAttachmentRef(value: string, label: string): `attachment://${string}` { + const trimmed = value.trim(); + if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) { + throw new Error(`${label} must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`); + } + const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim(); + if (!attachmentName) { + throw new Error(`${label} must include an attachment filename`); + } + return `${DISCORD_COMPONENT_ATTACHMENT_PREFIX}${attachmentName}`; +} + +export function resolveDiscordComponentAttachmentName(value: string): string { + const trimmed = value.trim(); + if (!trimmed.startsWith(DISCORD_COMPONENT_ATTACHMENT_PREFIX)) { + throw new Error( + `Attachment reference must start with "${DISCORD_COMPONENT_ATTACHMENT_PREFIX}"`, + ); + } + const attachmentName = trimmed.slice(DISCORD_COMPONENT_ATTACHMENT_PREFIX.length).trim(); + if (!attachmentName) { + throw new Error("Attachment reference must include a filename"); + } + return attachmentName; +} + +function mapButtonStyle(style?: DiscordComponentButtonStyle): ButtonStyle { + switch ((style ?? "primary").toLowerCase()) { + case "secondary": + return ButtonStyle.Secondary; + case "success": + return ButtonStyle.Success; + case "danger": + return ButtonStyle.Danger; + case "link": + return ButtonStyle.Link; + case "primary": + default: + return ButtonStyle.Primary; + } +} + +function mapTextInputStyle(style?: DiscordModalFieldSpec["style"]) { + return style === "paragraph" ? TextInputStyle.Paragraph : TextInputStyle.Short; +} + +function normalizeBlockType(raw: string) { + const lowered = raw.trim().toLowerCase(); + return BLOCK_ALIASES.get(lowered) ?? (lowered as DiscordComponentBlock["type"]); +} + +function parseSelectOptions( + raw: unknown, + label: string, +): DiscordComponentSelectOption[] | undefined { + if (raw === undefined) { + return undefined; + } + if (!Array.isArray(raw)) { + throw new Error(`${label} must be an array`); + } + return raw.map((entry, index) => { + const obj = requireObject(entry, `${label}[${index}]`); + return { + label: readString(obj.label, `${label}[${index}].label`), + value: readString(obj.value, `${label}[${index}].value`), + description: readOptionalString(obj.description), + emoji: + typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji) + ? { + name: readString( + (obj.emoji as { name?: unknown }).name, + `${label}[${index}].emoji.name`, + ), + id: readOptionalString((obj.emoji as { id?: unknown }).id), + animated: + typeof (obj.emoji as { animated?: unknown }).animated === "boolean" + ? (obj.emoji as { animated?: boolean }).animated + : undefined, + } + : undefined, + default: typeof obj.default === "boolean" ? obj.default : undefined, + }; + }); +} + +function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpec { + const obj = requireObject(raw, label); + const style = readOptionalString(obj.style) as DiscordComponentButtonStyle | undefined; + const url = readOptionalString(obj.url); + if ((style === "link" || url) && !url) { + throw new Error(`${label}.url is required for link buttons`); + } + return { + label: readString(obj.label, `${label}.label`), + style, + url, + emoji: + typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji) + ? { + name: readString((obj.emoji as { name?: unknown }).name, `${label}.emoji.name`), + id: readOptionalString((obj.emoji as { id?: unknown }).id), + animated: + typeof (obj.emoji as { animated?: unknown }).animated === "boolean" + ? (obj.emoji as { animated?: boolean }).animated + : undefined, + } + : undefined, + disabled: typeof obj.disabled === "boolean" ? obj.disabled : undefined, + }; +} + +function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpec { + const obj = requireObject(raw, label); + const type = readOptionalString(obj.type) as DiscordComponentSelectType | undefined; + const allowedTypes: DiscordComponentSelectType[] = [ + "string", + "user", + "role", + "mentionable", + "channel", + ]; + if (type && !allowedTypes.includes(type)) { + throw new Error(`${label}.type must be one of ${allowedTypes.join(", ")}`); + } + return { + type, + placeholder: readOptionalString(obj.placeholder), + minValues: readOptionalNumber(obj.minValues), + maxValues: readOptionalNumber(obj.maxValues), + options: parseSelectOptions(obj.options, `${label}.options`), + }; +} + +function parseModalField(raw: unknown, label: string, index: number): DiscordModalFieldSpec { + const obj = requireObject(raw, label); + const type = readString( + obj.type, + `${label}.type`, + ).toLowerCase() as DiscordComponentModalFieldType; + const supported: DiscordComponentModalFieldType[] = [ + "text", + "checkbox", + "radio", + "select", + "role-select", + "user-select", + ]; + if (!supported.includes(type)) { + throw new Error(`${label}.type must be one of ${supported.join(", ")}`); + } + const options = parseSelectOptions(obj.options, `${label}.options`); + if (["checkbox", "radio", "select"].includes(type) && (!options || options.length === 0)) { + throw new Error(`${label}.options is required for ${type} fields`); + } + return { + type, + name: normalizeModalFieldName(readOptionalString(obj.name), index), + label: readString(obj.label, `${label}.label`), + description: readOptionalString(obj.description), + placeholder: readOptionalString(obj.placeholder), + required: typeof obj.required === "boolean" ? obj.required : undefined, + options, + minValues: readOptionalNumber(obj.minValues), + maxValues: readOptionalNumber(obj.maxValues), + minLength: readOptionalNumber(obj.minLength), + maxLength: readOptionalNumber(obj.maxLength), + style: readOptionalString(obj.style) as DiscordModalFieldSpec["style"], + }; +} + +function parseComponentBlock(raw: unknown, label: string): DiscordComponentBlock { + const obj = requireObject(raw, label); + const typeRaw = readString(obj.type, `${label}.type`).toLowerCase(); + const type = normalizeBlockType(typeRaw); + switch (type) { + case "text": + return { + type: "text", + text: readString(obj.text, `${label}.text`), + }; + case "section": { + const text = readOptionalString(obj.text); + const textsRaw = obj.texts; + const texts = Array.isArray(textsRaw) + ? textsRaw.map((entry, idx) => readString(entry, `${label}.texts[${idx}]`)) + : undefined; + if (!text && (!texts || texts.length === 0)) { + throw new Error(`${label}.text or ${label}.texts is required for section blocks`); + } + let accessory: DiscordComponentSectionAccessory | undefined; + if (obj.accessory !== undefined) { + const accessoryObj = requireObject(obj.accessory, `${label}.accessory`); + const accessoryType = readString( + accessoryObj.type, + `${label}.accessory.type`, + ).toLowerCase(); + if (accessoryType === "thumbnail") { + accessory = { + type: "thumbnail", + url: readString(accessoryObj.url, `${label}.accessory.url`), + }; + } else if (accessoryType === "button") { + accessory = { + type: "button", + button: parseButtonSpec(accessoryObj.button, `${label}.accessory.button`), + }; + } else { + throw new Error(`${label}.accessory.type must be "thumbnail" or "button"`); + } + } + return { + type: "section", + text, + texts, + accessory, + }; + } + case "separator": { + const spacingRaw = obj.spacing; + let spacing: DiscordComponentSeparatorSpacing | undefined; + if (spacingRaw === "small" || spacingRaw === "large") { + spacing = spacingRaw; + } else if (spacingRaw === 1 || spacingRaw === 2) { + spacing = spacingRaw; + } else if (spacingRaw !== undefined) { + throw new Error(`${label}.spacing must be "small", "large", 1, or 2`); + } + const divider = typeof obj.divider === "boolean" ? obj.divider : undefined; + return { + type: "separator", + spacing, + divider, + }; + } + case "actions": { + const buttonsRaw = obj.buttons; + const buttons = Array.isArray(buttonsRaw) + ? buttonsRaw.map((entry, idx) => parseButtonSpec(entry, `${label}.buttons[${idx}]`)) + : undefined; + const select = obj.select ? parseSelectSpec(obj.select, `${label}.select`) : undefined; + if ((!buttons || buttons.length === 0) && !select) { + throw new Error(`${label} requires buttons or select`); + } + if (buttons && select) { + throw new Error(`${label} cannot include both buttons and select`); + } + return { + type: "actions", + buttons, + select, + }; + } + case "media-gallery": { + const itemsRaw = obj.items; + if (!Array.isArray(itemsRaw) || itemsRaw.length === 0) { + throw new Error(`${label}.items must be a non-empty array`); + } + const items = itemsRaw.map((entry, idx) => { + const itemObj = requireObject(entry, `${label}.items[${idx}]`); + return { + url: readString(itemObj.url, `${label}.items[${idx}].url`), + description: readOptionalString(itemObj.description), + spoiler: typeof itemObj.spoiler === "boolean" ? itemObj.spoiler : undefined, + }; + }); + return { + type: "media-gallery", + items, + }; + } + case "file": { + const file = readString(obj.file, `${label}.file`); + return { + type: "file", + file: normalizeAttachmentRef(file, `${label}.file`), + spoiler: typeof obj.spoiler === "boolean" ? obj.spoiler : undefined, + }; + } + default: + throw new Error(`${label}.type must be a supported component block`); + } +} + +export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageSpec | null { + if (raw === undefined || raw === null) { + return null; + } + const obj = requireObject(raw, "components"); + const blocksRaw = obj.blocks; + const blocks = Array.isArray(blocksRaw) + ? blocksRaw.map((entry, idx) => parseComponentBlock(entry, `components.blocks[${idx}]`)) + : undefined; + const modalRaw = obj.modal; + let modal: DiscordModalSpec | undefined; + if (modalRaw !== undefined) { + const modalObj = requireObject(modalRaw, "components.modal"); + const fieldsRaw = modalObj.fields; + if (!Array.isArray(fieldsRaw) || fieldsRaw.length === 0) { + throw new Error("components.modal.fields must be a non-empty array"); + } + if (fieldsRaw.length > 5) { + throw new Error("components.modal.fields supports up to 5 inputs"); + } + const fields = fieldsRaw.map((entry, idx) => + parseModalField(entry, `components.modal.fields[${idx}]`, idx), + ); + modal = { + title: readString(modalObj.title, "components.modal.title"), + triggerLabel: readOptionalString(modalObj.triggerLabel), + triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle, + fields, + }; + } + return { + text: readOptionalString(obj.text), + container: + typeof obj.container === "object" && obj.container && !Array.isArray(obj.container) + ? { + accentColor: (obj.container as { accentColor?: unknown }).accentColor as + | string + | number + | undefined, + spoiler: + typeof (obj.container as { spoiler?: unknown }).spoiler === "boolean" + ? ((obj.container as { spoiler?: boolean }).spoiler as boolean) + : undefined, + } + : undefined, + blocks, + modal, + }; +} + +export function buildDiscordComponentCustomId(params: { + componentId: string; + modalId?: string; +}): string { + const base = `${DISCORD_COMPONENT_CUSTOM_ID_KEY}:cid=${params.componentId}`; + return params.modalId ? `${base};mid=${params.modalId}` : base; +} + +export function buildDiscordModalCustomId(modalId: string): string { + return `${DISCORD_MODAL_CUSTOM_ID_KEY}:mid=${modalId}`; +} + +export function parseDiscordComponentCustomId( + id: string, +): { componentId: string; modalId?: string } | null { + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) { + return null; + } + const componentId = parsed.data.cid; + if (typeof componentId !== "string" || !componentId.trim()) { + return null; + } + const modalId = parsed.data.mid; + return { + componentId, + modalId: typeof modalId === "string" && modalId.trim() ? modalId : undefined, + }; +} + +export function parseDiscordModalCustomId(id: string): string | null { + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) { + return null; + } + const modalId = parsed.data.mid; + if (typeof modalId !== "string" || !modalId.trim()) { + return null; + } + return modalId; +} + +export function parseDiscordComponentCustomIdForCarbon(id: string): ComponentParserResult { + if (id === "*") { + return { key: "*", data: {} }; + } + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_COMPONENT_CUSTOM_ID_KEY) { + return parsed; + } + return { key: "*", data: parsed.data }; +} + +export function parseDiscordModalCustomIdForCarbon(id: string): ComponentParserResult { + if (id === "*") { + return { key: "*", data: {} }; + } + const parsed = parseCustomId(id); + if (parsed.key !== DISCORD_MODAL_CUSTOM_ID_KEY) { + return parsed; + } + return { key: "*", data: parsed.data }; +} + +function buildTextDisplays(text?: string, texts?: string[]): TextDisplay[] { + if (texts && texts.length > 0) { + return texts.map((entry) => new TextDisplay(entry)); + } + if (text) { + return [new TextDisplay(text)]; + } + return []; +} + +function createButtonComponent(params: { + spec: DiscordComponentButtonSpec; + componentId?: string; + modalId?: string; +}): { component: Button | LinkButton; entry?: DiscordComponentEntry } { + const style = mapButtonStyle(params.spec.style); + const isLink = style === ButtonStyle.Link || Boolean(params.spec.url); + if (isLink) { + if (!params.spec.url) { + throw new Error("Link buttons require a url"); + } + const linkUrl = params.spec.url; + class DynamicLinkButton extends LinkButton { + label = params.spec.label; + url = linkUrl; + } + return { component: new DynamicLinkButton() }; + } + const componentId = params.componentId ?? createShortId("btn_"); + const customId = buildDiscordComponentCustomId({ + componentId, + modalId: params.modalId, + }); + class DynamicButton extends Button { + label = params.spec.label; + customId = customId; + style = style; + emoji = params.spec.emoji; + disabled = params.spec.disabled ?? false; + } + return { + component: new DynamicButton(), + entry: { + id: componentId, + kind: params.modalId ? "modal-trigger" : "button", + label: params.spec.label, + modalId: params.modalId, + }, + }; +} + +function createSelectComponent(params: { + spec: DiscordComponentSelectSpec; + componentId?: string; +}): { + component: + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu; + entry: DiscordComponentEntry; +} { + const type = (params.spec.type ?? "string").toLowerCase() as DiscordComponentSelectType; + const componentId = params.componentId ?? createShortId("sel_"); + const customId = buildDiscordComponentCustomId({ componentId }); + if (type === "string") { + const options = params.spec.options ?? []; + if (options.length === 0) { + throw new Error("String select menus require options"); + } + class DynamicStringSelect extends StringSelectMenu { + customId = customId; + options = options; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicStringSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "select", + selectType: "string", + options: options.map((option) => ({ value: option.value, label: option.label })), + }, + }; + } + if (type === "user") { + class DynamicUserSelect extends UserSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicUserSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "user select", + selectType: "user", + }, + }; + } + if (type === "role") { + class DynamicRoleSelect extends RoleSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicRoleSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "role select", + selectType: "role", + }, + }; + } + if (type === "mentionable") { + class DynamicMentionableSelect extends MentionableSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicMentionableSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "mentionable select", + selectType: "mentionable", + }, + }; + } + class DynamicChannelSelect extends ChannelSelectMenu { + customId = customId; + minValues = params.spec.minValues; + maxValues = params.spec.maxValues; + placeholder = params.spec.placeholder; + disabled = false; + } + return { + component: new DynamicChannelSelect(), + entry: { + id: componentId, + kind: "select", + label: params.spec.placeholder ?? "channel select", + selectType: "channel", + }, + }; +} + +function isSelectComponent( + component: unknown, +): component is + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu { + return ( + component instanceof StringSelectMenu || + component instanceof UserSelectMenu || + component instanceof RoleSelectMenu || + component instanceof MentionableSelectMenu || + component instanceof ChannelSelectMenu + ); +} + +function createModalFieldComponent( + field: DiscordModalFieldDefinition, +): TextInput | StringSelectMenu | UserSelectMenu | RoleSelectMenu | CheckboxGroup | RadioGroup { + if (field.type === "text") { + class DynamicTextInput extends TextInput { + customId = field.id; + style = mapTextInputStyle(field.style); + placeholder = field.placeholder; + required = field.required; + minLength = field.minLength; + maxLength = field.maxLength; + } + return new DynamicTextInput(); + } + if (field.type === "select") { + const options = field.options ?? []; + class DynamicModalSelect extends StringSelectMenu { + customId = field.id; + options = options; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + placeholder = field.placeholder; + } + return new DynamicModalSelect(); + } + if (field.type === "role-select") { + class DynamicModalRoleSelect extends RoleSelectMenu { + customId = field.id; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + placeholder = field.placeholder; + } + return new DynamicModalRoleSelect(); + } + if (field.type === "user-select") { + class DynamicModalUserSelect extends UserSelectMenu { + customId = field.id; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + placeholder = field.placeholder; + } + return new DynamicModalUserSelect(); + } + if (field.type === "checkbox") { + const options = field.options ?? []; + class DynamicCheckboxGroup extends CheckboxGroup { + customId = field.id; + options = options; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + } + return new DynamicCheckboxGroup(); + } + const options = field.options ?? []; + class DynamicRadioGroup extends RadioGroup { + customId = field.id; + options = options; + required = field.required; + minValues = field.minValues; + maxValues = field.maxValues; + } + return new DynamicRadioGroup(); +} + +export function buildDiscordComponentMessage(params: { + spec: DiscordComponentMessageSpec; + fallbackText?: string; + sessionKey?: string; + agentId?: string; + accountId?: string; +}): DiscordComponentBuildResult { + const entries: DiscordComponentEntry[] = []; + const modals: DiscordModalEntry[] = []; + const components: TopLevelComponents[] = []; + const containerChildren: Array< + | Row< + | Button + | LinkButton + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu + > + | TextDisplay + | Section + | MediaGallery + | Separator + | File + > = []; + + const addEntry = (entry: DiscordComponentEntry) => { + entries.push({ + ...entry, + sessionKey: params.sessionKey, + agentId: params.agentId, + accountId: params.accountId, + }); + }; + + const text = params.spec.text ?? params.fallbackText; + if (text) { + containerChildren.push(new TextDisplay(text)); + } + + for (const block of params.spec.blocks ?? []) { + if (block.type === "text") { + containerChildren.push(new TextDisplay(block.text)); + continue; + } + if (block.type === "section") { + const displays = buildTextDisplays(block.text, block.texts); + if (displays.length > 3) { + throw new Error("Section blocks support up to 3 text displays"); + } + let accessory: Thumbnail | Button | LinkButton | undefined; + if (block.accessory?.type === "thumbnail") { + accessory = new Thumbnail(block.accessory.url); + } else if (block.accessory?.type === "button") { + const { component, entry } = createButtonComponent({ spec: block.accessory.button }); + accessory = component; + if (entry) { + addEntry(entry); + } + } + containerChildren.push(new Section(displays, accessory)); + continue; + } + if (block.type === "separator") { + containerChildren.push(new Separator({ spacing: block.spacing, divider: block.divider })); + continue; + } + if (block.type === "media-gallery") { + containerChildren.push(new MediaGallery(block.items)); + continue; + } + if (block.type === "file") { + containerChildren.push(new File(block.file, block.spoiler)); + continue; + } + if (block.type === "actions") { + const rowComponents: Array< + | Button + | LinkButton + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu + > = []; + if (block.buttons) { + if (block.buttons.length > 5) { + throw new Error("Action rows support up to 5 buttons"); + } + for (const button of block.buttons) { + const { component, entry } = createButtonComponent({ spec: button }); + rowComponents.push(component); + if (entry) { + addEntry(entry); + } + } + } else if (block.select) { + const { component, entry } = createSelectComponent({ spec: block.select }); + rowComponents.push(component); + addEntry(entry); + } + containerChildren.push(new Row(rowComponents)); + } + } + + if (params.spec.modal) { + const modalId = createShortId("mdl_"); + const fields = params.spec.modal.fields.map((field, index) => ({ + id: createShortId("fld_"), + name: normalizeModalFieldName(field.name, index), + label: field.label, + type: field.type, + description: field.description, + placeholder: field.placeholder, + required: field.required, + options: field.options, + minValues: field.minValues, + maxValues: field.maxValues, + minLength: field.minLength, + maxLength: field.maxLength, + style: field.style, + })); + modals.push({ + id: modalId, + title: params.spec.modal.title, + fields, + sessionKey: params.sessionKey, + agentId: params.agentId, + accountId: params.accountId, + }); + + const triggerSpec: DiscordComponentButtonSpec = { + label: params.spec.modal.triggerLabel ?? "Open form", + style: params.spec.modal.triggerStyle ?? "primary", + }; + + const { component, entry } = createButtonComponent({ + spec: triggerSpec, + modalId, + }); + + if (entry) { + addEntry(entry); + } + + const lastChild = containerChildren.at(-1); + if (lastChild instanceof Row) { + const row = lastChild; + const hasSelect = row.components.some((entry) => isSelectComponent(entry)); + if (row.components.length < 5 && !hasSelect) { + row.addComponent(component as Button); + } else { + containerChildren.push(new Row([component as Button])); + } + } else { + containerChildren.push(new Row([component as Button])); + } + } + + if (containerChildren.length === 0) { + throw new Error("components must include at least one block, text, or modal trigger"); + } + + const container = new Container(containerChildren, params.spec.container); + components.push(container); + return { components, entries, modals }; +} + +export function buildDiscordComponentMessageFlags( + components: TopLevelComponents[], +): number | undefined { + const hasV2 = components.some((component) => component.isV2); + return hasV2 ? MessageFlags.IsComponentsV2 : undefined; +} + +export class DiscordFormModal extends Modal { + title: string; + customId: string; + components: Array