diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 431f7a18802..ae7003351c9 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -1103,11 +1103,11 @@ openclaw logs --follow - `Slow listener detected ...` - `stuck session: sessionKey=agent:...:discord:... state=processing ...` - Carbon gateway queue knobs: + Discord gateway queue knobs: - single-account: `channels.discord.eventQueue.listenerTimeout` - multi-account: `channels.discord.accounts..eventQueue.listenerTimeout` - - this only controls Carbon gateway listener work, not agent turn lifetime + - this only controls Discord gateway listener work, not agent turn lifetime Discord does not apply a channel-owned timeout to queued agent turns. Message listeners hand off immediately, and queued Discord runs preserve per-session ordering until the session/tool/runtime lifecycle completes or aborts the work. diff --git a/extensions/discord/api.ts b/extensions/discord/api.ts index 698eceacaea..51030369ffb 100644 --- a/extensions/discord/api.ts +++ b/extensions/discord/api.ts @@ -52,8 +52,10 @@ export { formatDiscordComponentEventText, parseDiscordComponentCustomId, parseDiscordComponentCustomIdForCarbon, + parseDiscordComponentCustomIdForInteraction, parseDiscordModalCustomId, parseDiscordModalCustomIdForCarbon, + parseDiscordModalCustomIdForInteraction, readDiscordComponentSpec, resolveDiscordComponentAttachmentName, } from "./src/components.js"; diff --git a/extensions/discord/package.json b/extensions/discord/package.json index 29ce00f74f5..0e219ba258b 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -4,11 +4,11 @@ "description": "OpenClaw Discord channel plugin", "type": "module", "dependencies": { - "@buape/carbon": "0.16.0", "@discordjs/voice": "^0.19.2", "discord-api-types": "^0.38.47", "https-proxy-agent": "^9.0.0", "opusscript": "^0.1.1", + "typebox": "1.1.33", "undici": "8.1.0", "ws": "^8.20.0" }, diff --git a/extensions/discord/src/actions/runtime.presence.test.ts b/extensions/discord/src/actions/runtime.presence.test.ts index 867e74637f3..d1054442a8a 100644 --- a/extensions/discord/src/actions/runtime.presence.test.ts +++ b/extensions/discord/src/actions/runtime.presence.test.ts @@ -1,6 +1,6 @@ -import type { GatewayPlugin } from "@buape/carbon/gateway"; import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-types"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { GatewayPlugin } from "../internal/gateway.js"; import { clearGateways, registerGateway } from "../monitor/gateway-registry.js"; import type { ActionGate } from "../runtime-api.js"; import { handleDiscordPresenceAction } from "./runtime.presence.js"; diff --git a/extensions/discord/src/actions/runtime.presence.ts b/extensions/discord/src/actions/runtime.presence.ts index 26dfdf7ce98..007df964b42 100644 --- a/extensions/discord/src/actions/runtime.presence.ts +++ b/extensions/discord/src/actions/runtime.presence.ts @@ -1,6 +1,6 @@ -import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway"; import type { AgentToolResult } from "@mariozechner/pi-agent-core"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import type { Activity, UpdatePresenceData } from "../internal/gateway.js"; import { getGateway } from "../monitor/gateway-registry.js"; import { type ActionGate, diff --git a/extensions/discord/src/approval-handler.runtime.ts b/extensions/discord/src/approval-handler.runtime.ts index 94e150af710..ca9723bc451 100644 --- a/extensions/discord/src/approval-handler.runtime.ts +++ b/extensions/discord/src/approval-handler.runtime.ts @@ -1,13 +1,4 @@ -import { - Button, - Row, - Separator, - TextDisplay, - serializePayload, - type MessagePayloadObject, - type TopLevelComponents, -} from "@buape/carbon"; -import { ButtonStyle, Routes } from "discord-api-types/v10"; +import { ButtonStyle } from "discord-api-types/v10"; import type { ChannelApprovalCapabilityHandlerContext, ExecApprovalExpiredView, @@ -25,6 +16,19 @@ import type { DiscordExecApprovalConfig, OpenClawConfig } from "openclaw/plugin- import { logDebug, logError, normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { shouldHandleDiscordApprovalRequest } from "./approval-shared.js"; import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js"; +import { + Button, + createChannelMessage, + createUserDmChannel, + deleteChannelMessage, + editChannelMessage, + Row, + Separator, + TextDisplay, + serializePayload, + type MessagePayloadObject, + type TopLevelComponents, +} from "./internal/discord.js"; import { createDiscordClient, stripUndefinedFields } from "./send.shared.js"; import { DiscordUiContainer } from "./ui.js"; @@ -364,7 +368,7 @@ async function updateMessage(params: { const payload = buildExecApprovalPayload(params.container); await discordRequest( () => - rest.patch(Routes.channelMessage(params.channelId, params.messageId), { + editChannelMessage(rest, params.channelId, params.messageId, { body: stripUndefinedFields(serializePayload(payload)), }), "update-approval", @@ -394,7 +398,7 @@ async function finalizeMessage(params: { accountId: params.accountId, }); await discordRequest( - () => rest.delete(Routes.channelMessage(params.channelId, params.messageId)) as Promise, + () => deleteChannelMessage(rest, params.channelId, params.messageId), "delete-approval", ); } catch (err) { @@ -524,10 +528,7 @@ export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAd }); const userId = plannedTarget.target.to; const dmChannel = (await discordRequest( - () => - rest.post(Routes.userChannels(), { - body: { recipient_id: userId }, - }) as Promise<{ id: string }>, + () => createUserDmChannel(rest, userId), "dm-channel", )) as { id: string }; if (!dmChannel?.id) { @@ -561,9 +562,13 @@ export const discordApprovalNativeRuntime = createChannelApprovalNativeRuntimeAd }); const message = (await discordRequest( () => - rest.post(Routes.channelMessages(preparedTarget.discordChannelId), { - body: pendingPayload.body, - }) as Promise<{ id: string; channel_id: string }>, + createChannelMessage<{ id: string; channel_id: string }>( + rest, + preparedTarget.discordChannelId, + { + body: pendingPayload.body, + }, + ), plannedTarget.surface === "origin" ? "send-approval-channel" : "send-approval", )) as { id: string; channel_id: string }; if (!message?.id) { diff --git a/extensions/discord/src/client.test.ts b/extensions/discord/src/client.test.ts index 20bf9587b7a..38f7e1eebb6 100644 --- a/extensions/discord/src/client.test.ts +++ b/extensions/discord/src/client.test.ts @@ -1,7 +1,13 @@ -import type { RequestClient } from "@buape/carbon"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { describe, expect, it } from "vitest"; +import { + parseDiscordComponentCustomIdForCarbon, + parseDiscordComponentCustomIdForInteraction, + parseDiscordModalCustomIdForCarbon, + parseDiscordModalCustomIdForInteraction, +} from "../api.js"; import { createDiscordRestClient } from "./client.js"; +import type { RequestClient } from "./internal/discord.js"; describe("createDiscordRestClient", () => { const fakeRest = {} as RequestClient; @@ -74,3 +80,14 @@ describe("createDiscordRestClient", () => { expect(() => createDiscordRestClient({ cfg, rest: fakeRest })).toThrow(/unresolved SecretRef/i); }); }); + +describe("public Discord API compatibility", () => { + it("keeps legacy Carbon parser aliases wired to the interaction parsers", () => { + expect(parseDiscordComponentCustomIdForCarbon).toBe( + parseDiscordComponentCustomIdForInteraction, + ); + expect(parseDiscordModalCustomIdForCarbon).toBe(parseDiscordModalCustomIdForInteraction); + expect(parseDiscordComponentCustomIdForCarbon("occomp:cid=one").data.cid).toBe("one"); + expect(parseDiscordModalCustomIdForCarbon("ocmodal:mid=two").data.mid).toBe("two"); + }); +}); diff --git a/extensions/discord/src/client.ts b/extensions/discord/src/client.ts index bba03322410..95597e83023 100644 --- a/extensions/discord/src/client.ts +++ b/extensions/discord/src/client.ts @@ -1,4 +1,3 @@ -import { RequestClient } from "@buape/carbon"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import type { RetryConfig, RetryRunner } from "openclaw/plugin-sdk/retry-runtime"; @@ -10,6 +9,7 @@ import { resolveDiscordAccount, type ResolvedDiscordAccount, } from "./accounts.js"; +import { RequestClient } from "./internal/discord.js"; import { resolveDiscordProxyFetchForAccount } from "./proxy-fetch.js"; import { createDiscordRequestClient } from "./proxy-request-client.js"; import { createDiscordRetryRunner } from "./retry.js"; diff --git a/extensions/discord/src/component-custom-id.ts b/extensions/discord/src/component-custom-id.ts index 6005ad7e079..fc56dfa9411 100644 --- a/extensions/discord/src/component-custom-id.ts +++ b/extensions/discord/src/component-custom-id.ts @@ -1,4 +1,4 @@ -import { parseCustomId, type ComponentParserResult } from "@buape/carbon"; +import { parseCustomId, type ComponentParserResult } from "./internal/discord.js"; export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp"; export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal"; @@ -49,7 +49,7 @@ function isDiscordComponentWildcardRegistrationId(id: string): boolean { return /^__openclaw_discord_component_[a-z_]+_wildcard__$/.test(id); } -export function parseDiscordComponentCustomIdForCarbon(id: string): ComponentParserResult { +export function parseDiscordComponentCustomIdForInteraction(id: string): ComponentParserResult { if (id === "*" || isDiscordComponentWildcardRegistrationId(id)) { return { key: "*", data: {} }; } @@ -60,7 +60,9 @@ export function parseDiscordComponentCustomIdForCarbon(id: string): ComponentPar return { key: "*", data: parsed.data }; } -export function parseDiscordModalCustomIdForCarbon(id: string): ComponentParserResult { +export const parseDiscordComponentCustomIdForCarbon = parseDiscordComponentCustomIdForInteraction; + +export function parseDiscordModalCustomIdForInteraction(id: string): ComponentParserResult { if (id === "*" || isDiscordComponentWildcardRegistrationId(id)) { return { key: "*", data: {} }; } @@ -70,3 +72,5 @@ export function parseDiscordModalCustomIdForCarbon(id: string): ComponentParserR } return { key: "*", data: parsed.data }; } + +export const parseDiscordModalCustomIdForCarbon = parseDiscordModalCustomIdForInteraction; diff --git a/extensions/discord/src/components.builders.ts b/extensions/discord/src/components.builders.ts new file mode 100644 index 00000000000..87bc80c28cf --- /dev/null +++ b/extensions/discord/src/components.builders.ts @@ -0,0 +1,421 @@ +import crypto from "node:crypto"; +import { ButtonStyle, MessageFlags } from "discord-api-types/v10"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { buildDiscordComponentCustomId as buildDiscordComponentCustomIdImpl } from "./component-custom-id.js"; +import { mapButtonStyle, normalizeModalFieldName } from "./components.parse.js"; +import type { + DiscordComponentBuildResult, + DiscordComponentButtonSpec, + DiscordComponentEntry, + DiscordComponentMessageSpec, + DiscordComponentSelectSpec, + DiscordComponentSelectType, + DiscordModalEntry, +} from "./components.types.js"; +import { + Button, + ChannelSelectMenu, + Container, + File, + LinkButton, + MediaGallery, + MentionableSelectMenu, + RoleSelectMenu, + Row, + Section, + Separator, + StringSelectMenu, + TextDisplay, + Thumbnail, + UserSelectMenu, + type TopLevelComponents, +} from "./internal/discord.js"; + +function createShortId(prefix: string) { + return `${prefix}${crypto.randomBytes(6).toString("base64url")}`; +} + +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 internalCustomId = + typeof params.spec.internalCustomId === "string" && params.spec.internalCustomId.trim() + ? params.spec.internalCustomId.trim() + : undefined; + const customId = + internalCustomId ?? + buildDiscordComponentCustomIdImpl({ + 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; + } + if (internalCustomId) { + return { + component: new DynamicButton(), + }; + } + return { + component: new DynamicButton(), + entry: { + id: componentId, + kind: params.modalId ? "modal-trigger" : "button", + label: params.spec.label, + callbackData: params.spec.callbackData, + modalId: params.modalId, + allowedUsers: params.spec.allowedUsers, + }, + }; +} + +function createSelectComponent(params: { + spec: DiscordComponentSelectSpec; + componentId?: string; +}): { + component: + | StringSelectMenu + | UserSelectMenu + | RoleSelectMenu + | MentionableSelectMenu + | ChannelSelectMenu; + entry: DiscordComponentEntry; +} { + const type = normalizeLowercaseStringOrEmpty( + params.spec.type ?? "string", + ) as DiscordComponentSelectType; + const componentId = params.componentId ?? createShortId("sel_"); + const customId = buildDiscordComponentCustomIdImpl({ 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", + callbackData: params.spec.callbackData, + selectType: "string", + options: options.map((option) => ({ value: option.value, label: option.label })), + allowedUsers: params.spec.allowedUsers, + }, + }; + } + 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", + callbackData: params.spec.callbackData, + selectType: "user", + allowedUsers: params.spec.allowedUsers, + }, + }; + } + 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", + callbackData: params.spec.callbackData, + selectType: "role", + allowedUsers: params.spec.allowedUsers, + }, + }; + } + 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", + callbackData: params.spec.callbackData, + selectType: "mentionable", + allowedUsers: params.spec.allowedUsers, + }, + }; + } + 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", + callbackData: params.spec.callbackData, + selectType: "channel", + allowedUsers: params.spec.allowedUsers, + }, + }; +} + +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 + ); +} + +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, + reusable: entry.reusable ?? params.spec.reusable, + }); + }; + + 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, + callbackData: params.spec.modal.callbackData, + fields, + sessionKey: params.sessionKey, + agentId: params.agentId, + accountId: params.accountId, + reusable: params.spec.reusable, + allowedUsers: params.spec.modal.allowedUsers, + }); + + const triggerSpec: DiscordComponentButtonSpec = { + label: params.spec.modal.triggerLabel ?? "Open form", + style: params.spec.modal.triggerStyle ?? "primary", + allowedUsers: params.spec.modal.allowedUsers, + }; + + 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; +} diff --git a/extensions/discord/src/components.modal.ts b/extensions/discord/src/components.modal.ts new file mode 100644 index 00000000000..626d07e4714 --- /dev/null +++ b/extensions/discord/src/components.modal.ts @@ -0,0 +1,124 @@ +import { + buildDiscordModalCustomId as buildDiscordModalCustomIdImpl, + parseDiscordModalCustomIdForInteraction as parseDiscordModalCustomIdForInteractionImpl, +} from "./component-custom-id.js"; +import { mapTextInputStyle } from "./components.parse.js"; +import type { DiscordModalEntry, DiscordModalFieldDefinition } from "./components.types.js"; +import { + CheckboxGroup, + Label, + Modal, + RadioGroup, + RoleSelectMenu, + StringSelectMenu, + TextDisplay, + TextInput, + UserSelectMenu, +} from "./internal/discord.js"; + +// Some test-only module graphs partially mock `./internal/discord.js` and can drop `Modal`. +// Keep dynamic form definitions loadable instead of crashing unrelated suites. +const ModalBase: typeof Modal = Modal ?? (function ModalFallback() {} as unknown as typeof Modal); + +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 class DiscordFormModal extends ModalBase { + title: string; + customId: string; + components: Array