1313 lines
39 KiB
TypeScript
1313 lines
39 KiB
TypeScript
import {
|
|
Button,
|
|
ChannelSelectMenu,
|
|
MentionableSelectMenu,
|
|
Modal,
|
|
RoleSelectMenu,
|
|
StringSelectMenu,
|
|
UserSelectMenu,
|
|
type ButtonInteraction,
|
|
type ChannelSelectMenuInteraction,
|
|
type ComponentData,
|
|
type MentionableSelectMenuInteraction,
|
|
type ModalInteraction,
|
|
type RoleSelectMenuInteraction,
|
|
type StringSelectMenuInteraction,
|
|
type TopLevelComponents,
|
|
type UserSelectMenuInteraction,
|
|
} from "@buape/carbon";
|
|
import type { APIStringSelectComponent } from "discord-api-types/v10";
|
|
import { ButtonStyle, ChannelType } from "discord-api-types/v10";
|
|
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
|
import {
|
|
formatInboundEnvelope,
|
|
resolveEnvelopeFormatOptions,
|
|
} from "openclaw/plugin-sdk/channel-inbound";
|
|
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
|
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
|
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
|
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
|
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
|
import {
|
|
buildPluginBindingResolvedText,
|
|
parsePluginBindingApprovalCustomId,
|
|
resolvePluginConversationBindingApproval,
|
|
} from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
|
|
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
|
|
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
|
import {
|
|
dispatchPluginInteractiveHandler,
|
|
type PluginInteractiveDiscordHandlerContext,
|
|
} from "openclaw/plugin-sdk/plugin-runtime";
|
|
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
|
|
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
|
|
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
|
|
import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
|
|
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
|
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
|
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
|
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
|
|
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
|
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
|
|
import {
|
|
createDiscordFormModal,
|
|
formatDiscordComponentEventText,
|
|
parseDiscordComponentCustomIdForCarbon,
|
|
parseDiscordModalCustomIdForCarbon,
|
|
type DiscordComponentEntry,
|
|
type DiscordModalEntry,
|
|
} from "../components.js";
|
|
import {
|
|
AGENT_BUTTON_KEY,
|
|
AGENT_SELECT_KEY,
|
|
ackComponentInteraction,
|
|
buildAgentButtonCustomId,
|
|
buildAgentSelectCustomId,
|
|
type AgentComponentContext,
|
|
type AgentComponentInteraction,
|
|
type AgentComponentMessageInteraction,
|
|
ensureAgentComponentInteractionAllowed,
|
|
ensureComponentUserAllowed,
|
|
ensureGuildComponentMemberAllowed,
|
|
formatModalSubmissionText,
|
|
mapSelectValues,
|
|
parseAgentComponentData,
|
|
parseDiscordComponentData,
|
|
parseDiscordModalId,
|
|
resolveAgentComponentRoute,
|
|
resolveComponentCommandAuthorized,
|
|
type ComponentInteractionContext,
|
|
resolveDiscordChannelContext,
|
|
type DiscordChannelContext,
|
|
resolveDiscordInteractionId,
|
|
resolveInteractionContextWithDmAuth,
|
|
resolveInteractionCustomId,
|
|
resolveModalFieldValues,
|
|
resolvePinnedMainDmOwnerFromAllowlist,
|
|
type DiscordUser,
|
|
} from "./agent-components-helpers.js";
|
|
import {
|
|
type DiscordGuildEntryResolved,
|
|
normalizeDiscordAllowList,
|
|
resolveDiscordChannelConfigWithFallback,
|
|
resolveDiscordGuildEntry,
|
|
} from "./allow-list.js";
|
|
import { formatDiscordUserTag } from "./format.js";
|
|
import {
|
|
buildDiscordInboundAccessContext,
|
|
buildDiscordGroupSystemPrompt,
|
|
} from "./inbound-context.js";
|
|
import { buildDirectLabel, buildGuildLabel } from "./reply-context.js";
|
|
import { deliverDiscordReply } from "./reply-delivery.js";
|
|
import { sendTyping } from "./typing.js";
|
|
|
|
async function dispatchPluginDiscordInteractiveEvent(params: {
|
|
ctx: AgentComponentContext;
|
|
interaction: AgentComponentInteraction;
|
|
interactionCtx: ComponentInteractionContext;
|
|
channelCtx: DiscordChannelContext;
|
|
isAuthorizedSender: boolean;
|
|
data: string;
|
|
kind: "button" | "select" | "modal";
|
|
values?: string[];
|
|
fields?: Array<{ id: string; name: string; values: string[] }>;
|
|
messageId?: string;
|
|
}): Promise<"handled" | "unmatched"> {
|
|
const normalizedConversationId =
|
|
params.interactionCtx.rawGuildId || params.channelCtx.channelType === ChannelType.GroupDM
|
|
? `channel:${params.interactionCtx.channelId}`
|
|
: `user:${params.interactionCtx.userId}`;
|
|
let responded = false;
|
|
const respond: PluginInteractiveDiscordHandlerContext["respond"] = {
|
|
acknowledge: async () => {
|
|
responded = true;
|
|
await params.interaction.acknowledge();
|
|
},
|
|
reply: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
|
|
responded = true;
|
|
await params.interaction.reply({
|
|
content: text,
|
|
ephemeral,
|
|
});
|
|
},
|
|
followUp: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
|
|
responded = true;
|
|
await params.interaction.followUp({
|
|
content: text,
|
|
ephemeral,
|
|
});
|
|
},
|
|
editMessage: async (input) => {
|
|
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
|
|
throw new Error("Discord interaction cannot update the source message");
|
|
}
|
|
const { text, components } = input;
|
|
responded = true;
|
|
await params.interaction.update({
|
|
...(text !== undefined ? { content: text } : {}),
|
|
...(components !== undefined ? { components: components as TopLevelComponents[] } : {}),
|
|
});
|
|
},
|
|
clearComponents: async (input?: { text?: string }) => {
|
|
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
|
|
throw new Error("Discord interaction cannot clear components on the source message");
|
|
}
|
|
responded = true;
|
|
await params.interaction.update({
|
|
...(input?.text !== undefined ? { content: input.text } : {}),
|
|
components: [],
|
|
});
|
|
},
|
|
};
|
|
const pluginBindingApproval = parsePluginBindingApprovalCustomId(params.data);
|
|
if (pluginBindingApproval) {
|
|
const resolved = await resolvePluginConversationBindingApproval({
|
|
approvalId: pluginBindingApproval.approvalId,
|
|
decision: pluginBindingApproval.decision,
|
|
senderId: params.interactionCtx.userId,
|
|
});
|
|
let cleared = false;
|
|
try {
|
|
await respond.clearComponents();
|
|
cleared = true;
|
|
} catch {
|
|
try {
|
|
await respond.acknowledge();
|
|
} catch {
|
|
// Interaction may already be acknowledged; continue with best-effort follow-up.
|
|
}
|
|
}
|
|
try {
|
|
await respond.followUp({
|
|
text: buildPluginBindingResolvedText(resolved),
|
|
ephemeral: true,
|
|
});
|
|
} catch (err) {
|
|
logError(`discord plugin binding approval: failed to follow up: ${String(err)}`);
|
|
if (!cleared) {
|
|
try {
|
|
await respond.reply({
|
|
text: buildPluginBindingResolvedText(resolved),
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may no longer accept a direct reply.
|
|
}
|
|
}
|
|
}
|
|
return "handled";
|
|
}
|
|
const dispatched = await dispatchPluginInteractiveHandler({
|
|
channel: "discord",
|
|
data: params.data,
|
|
interactionId: resolveDiscordInteractionId(params.interaction),
|
|
ctx: {
|
|
accountId: params.ctx.accountId,
|
|
interactionId: resolveDiscordInteractionId(params.interaction),
|
|
conversationId: normalizedConversationId,
|
|
parentConversationId: params.channelCtx.parentId,
|
|
guildId: params.interactionCtx.rawGuildId,
|
|
senderId: params.interactionCtx.userId,
|
|
senderUsername: params.interactionCtx.username,
|
|
auth: { isAuthorizedSender: params.isAuthorizedSender },
|
|
interaction: {
|
|
kind: params.kind,
|
|
messageId: params.messageId,
|
|
values: params.values,
|
|
fields: params.fields,
|
|
},
|
|
},
|
|
respond,
|
|
});
|
|
if (!dispatched.matched) {
|
|
return "unmatched";
|
|
}
|
|
if (dispatched.handled) {
|
|
if (!responded) {
|
|
try {
|
|
await respond.acknowledge();
|
|
} catch {
|
|
// Interaction may have expired after the handler finished.
|
|
}
|
|
}
|
|
return "handled";
|
|
}
|
|
return "unmatched";
|
|
}
|
|
|
|
async function dispatchDiscordComponentEvent(params: {
|
|
ctx: AgentComponentContext;
|
|
interaction: AgentComponentInteraction;
|
|
interactionCtx: ComponentInteractionContext;
|
|
channelCtx: DiscordChannelContext;
|
|
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
|
|
eventText: string;
|
|
replyToId?: string;
|
|
routeOverrides?: { sessionKey?: string; agentId?: string; accountId?: string };
|
|
}): Promise<void> {
|
|
const { ctx, interaction, interactionCtx, channelCtx, guildInfo, eventText } = params;
|
|
const runtime = ctx.runtime ?? createNonExitingRuntime();
|
|
const route = resolveAgentRoute({
|
|
cfg: ctx.cfg,
|
|
channel: "discord",
|
|
accountId: ctx.accountId,
|
|
guildId: interactionCtx.rawGuildId,
|
|
memberRoleIds: interactionCtx.memberRoleIds,
|
|
peer: {
|
|
kind: interactionCtx.isDirectMessage ? "direct" : "channel",
|
|
id: interactionCtx.isDirectMessage ? interactionCtx.userId : interactionCtx.channelId,
|
|
},
|
|
parentPeer: channelCtx.parentId ? { kind: "channel", id: channelCtx.parentId } : undefined,
|
|
});
|
|
const sessionKey = params.routeOverrides?.sessionKey ?? route.sessionKey;
|
|
const agentId = params.routeOverrides?.agentId ?? route.agentId;
|
|
const accountId = params.routeOverrides?.accountId ?? route.accountId;
|
|
|
|
const fromLabel = interactionCtx.isDirectMessage
|
|
? buildDirectLabel(interactionCtx.user)
|
|
: buildGuildLabel({
|
|
guild: interaction.guild ?? undefined,
|
|
channelName: channelCtx.channelName ?? interactionCtx.channelId,
|
|
channelId: interactionCtx.channelId,
|
|
});
|
|
const senderName = interactionCtx.user.globalName ?? interactionCtx.user.username;
|
|
const senderUsername = interactionCtx.user.username;
|
|
const senderTag = formatDiscordUserTag(interactionCtx.user);
|
|
const groupChannel =
|
|
!interactionCtx.isDirectMessage && channelCtx.channelSlug
|
|
? `#${channelCtx.channelSlug}`
|
|
: undefined;
|
|
const groupSubject = interactionCtx.isDirectMessage ? undefined : groupChannel;
|
|
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
|
guildInfo,
|
|
channelId: interactionCtx.channelId,
|
|
channelName: channelCtx.channelName,
|
|
channelSlug: channelCtx.channelSlug,
|
|
parentId: channelCtx.parentId,
|
|
parentName: channelCtx.parentName,
|
|
parentSlug: channelCtx.parentSlug,
|
|
scope: channelCtx.isThread ? "thread" : "channel",
|
|
});
|
|
const allowNameMatching = isDangerousNameMatchingEnabled(ctx.discordConfig);
|
|
const { ownerAllowFrom } = buildDiscordInboundAccessContext({
|
|
channelConfig,
|
|
guildInfo,
|
|
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
|
|
allowNameMatching,
|
|
isGuild: !interactionCtx.isDirectMessage,
|
|
});
|
|
const groupSystemPrompt = buildDiscordGroupSystemPrompt(channelConfig);
|
|
const pinnedMainDmOwner = interactionCtx.isDirectMessage
|
|
? resolvePinnedMainDmOwnerFromAllowlist({
|
|
dmScope: ctx.cfg.session?.dmScope,
|
|
allowFrom: channelConfig?.users ?? guildInfo?.users,
|
|
normalizeEntry: (entry: string) => {
|
|
const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]);
|
|
const candidate = normalized?.ids.values().next().value;
|
|
return typeof candidate === "string" && /^\d+$/.test(candidate) ? candidate : undefined;
|
|
},
|
|
})
|
|
: null;
|
|
const commandAuthorized = resolveComponentCommandAuthorized({
|
|
ctx,
|
|
interactionCtx,
|
|
channelConfig,
|
|
guildInfo,
|
|
allowNameMatching,
|
|
});
|
|
const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId });
|
|
const envelopeOptions = resolveEnvelopeFormatOptions(ctx.cfg);
|
|
const previousTimestamp = readSessionUpdatedAt({
|
|
storePath,
|
|
sessionKey,
|
|
});
|
|
const timestamp = Date.now();
|
|
const combinedBody = formatInboundEnvelope({
|
|
channel: "Discord",
|
|
from: fromLabel,
|
|
timestamp,
|
|
body: eventText,
|
|
chatType: interactionCtx.isDirectMessage ? "direct" : "channel",
|
|
senderLabel: senderName,
|
|
previousTimestamp,
|
|
envelope: envelopeOptions,
|
|
});
|
|
|
|
const ctxPayload = finalizeInboundContext({
|
|
Body: combinedBody,
|
|
BodyForAgent: eventText,
|
|
RawBody: eventText,
|
|
CommandBody: eventText,
|
|
From: interactionCtx.isDirectMessage
|
|
? `discord:${interactionCtx.userId}`
|
|
: `discord:channel:${interactionCtx.channelId}`,
|
|
To: `channel:${interactionCtx.channelId}`,
|
|
SessionKey: sessionKey,
|
|
AccountId: accountId,
|
|
ChatType: interactionCtx.isDirectMessage ? "direct" : "channel",
|
|
ConversationLabel: fromLabel,
|
|
SenderName: senderName,
|
|
SenderId: interactionCtx.userId,
|
|
SenderUsername: senderUsername,
|
|
SenderTag: senderTag,
|
|
GroupSubject: groupSubject,
|
|
GroupChannel: groupChannel,
|
|
GroupSystemPrompt: interactionCtx.isDirectMessage ? undefined : groupSystemPrompt,
|
|
GroupSpace: guildInfo?.id ?? guildInfo?.slug ?? interactionCtx.rawGuildId ?? undefined,
|
|
OwnerAllowFrom: ownerAllowFrom,
|
|
Provider: "discord" as const,
|
|
Surface: "discord" as const,
|
|
WasMentioned: true,
|
|
CommandAuthorized: commandAuthorized,
|
|
CommandSource: "text" as const,
|
|
MessageSid: interaction.rawData.id,
|
|
Timestamp: timestamp,
|
|
OriginatingChannel: "discord" as const,
|
|
OriginatingTo: `channel:${interactionCtx.channelId}`,
|
|
});
|
|
|
|
await recordInboundSession({
|
|
storePath,
|
|
sessionKey: ctxPayload.SessionKey ?? sessionKey,
|
|
ctx: ctxPayload,
|
|
updateLastRoute: interactionCtx.isDirectMessage
|
|
? {
|
|
sessionKey: route.mainSessionKey,
|
|
channel: "discord",
|
|
to: `user:${interactionCtx.userId}`,
|
|
accountId,
|
|
mainDmOwnerPin: pinnedMainDmOwner
|
|
? {
|
|
ownerRecipient: pinnedMainDmOwner,
|
|
senderRecipient: interactionCtx.userId,
|
|
onSkip: ({ ownerRecipient, senderRecipient }) => {
|
|
logVerbose(
|
|
`discord: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
|
|
);
|
|
},
|
|
}
|
|
: undefined,
|
|
}
|
|
: undefined,
|
|
onRecordError: (err) => {
|
|
logVerbose(`discord: failed updating component session meta: ${String(err)}`);
|
|
},
|
|
});
|
|
|
|
const deliverTarget = `channel:${interactionCtx.channelId}`;
|
|
const typingChannelId = interactionCtx.channelId;
|
|
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
|
cfg: ctx.cfg,
|
|
agentId,
|
|
channel: "discord",
|
|
accountId,
|
|
});
|
|
const tableMode = resolveMarkdownTableMode({
|
|
cfg: ctx.cfg,
|
|
channel: "discord",
|
|
accountId,
|
|
});
|
|
const textLimit = resolveTextChunkLimit(ctx.cfg, "discord", accountId, {
|
|
fallbackLimit: 2000,
|
|
});
|
|
const token = ctx.token ?? "";
|
|
const mediaLocalRoots = getAgentScopedMediaLocalRoots(ctx.cfg, agentId);
|
|
const replyToMode =
|
|
ctx.discordConfig?.replyToMode ?? ctx.cfg.channels?.discord?.replyToMode ?? "off";
|
|
const replyReference = createReplyReferencePlanner({
|
|
replyToMode,
|
|
startId: params.replyToId,
|
|
});
|
|
|
|
await dispatchReplyWithBufferedBlockDispatcher({
|
|
ctx: ctxPayload,
|
|
cfg: ctx.cfg,
|
|
replyOptions: { onModelSelected },
|
|
dispatcherOptions: {
|
|
...replyPipeline,
|
|
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
|
|
deliver: async (payload) => {
|
|
const replyToId = replyReference.use();
|
|
await deliverDiscordReply({
|
|
cfg: ctx.cfg,
|
|
replies: [payload],
|
|
target: deliverTarget,
|
|
token,
|
|
accountId,
|
|
rest: interaction.client.rest,
|
|
runtime,
|
|
replyToId,
|
|
replyToMode,
|
|
textLimit,
|
|
maxLinesPerMessage: resolveDiscordMaxLinesPerMessage({
|
|
cfg: ctx.cfg,
|
|
discordConfig: ctx.discordConfig,
|
|
accountId,
|
|
}),
|
|
tableMode,
|
|
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
|
|
mediaLocalRoots,
|
|
});
|
|
replyReference.markSent();
|
|
},
|
|
onReplyStart: async () => {
|
|
try {
|
|
await sendTyping({ client: interaction.client, channelId: typingChannelId });
|
|
} catch (err) {
|
|
logVerbose(`discord: typing failed for component reply: ${String(err)}`);
|
|
}
|
|
},
|
|
onError: (err) => {
|
|
logError(`discord component dispatch failed: ${String(err)}`);
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleDiscordComponentEvent(params: {
|
|
ctx: AgentComponentContext;
|
|
interaction: AgentComponentMessageInteraction;
|
|
data: ComponentData;
|
|
componentLabel: string;
|
|
values?: string[];
|
|
label: string;
|
|
}): Promise<void> {
|
|
const parsed = parseDiscordComponentData(
|
|
params.data,
|
|
resolveInteractionCustomId(params.interaction),
|
|
);
|
|
if (!parsed) {
|
|
logError(`${params.label}: failed to parse component data`);
|
|
try {
|
|
await params.interaction.reply({
|
|
content: "This component is no longer valid.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
const entry = resolveDiscordComponentEntry({ id: parsed.componentId, consume: false });
|
|
if (!entry) {
|
|
try {
|
|
await params.interaction.reply({
|
|
content: "This component has expired.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
const interactionCtx = await resolveInteractionContextWithDmAuth({
|
|
ctx: params.ctx,
|
|
interaction: params.interaction,
|
|
label: params.label,
|
|
componentLabel: params.componentLabel,
|
|
});
|
|
if (!interactionCtx) {
|
|
return;
|
|
}
|
|
const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx;
|
|
const guildInfo = resolveDiscordGuildEntry({
|
|
guild: params.interaction.guild ?? undefined,
|
|
guildId: rawGuildId,
|
|
guildEntries: params.ctx.guildEntries,
|
|
});
|
|
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
|
const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig);
|
|
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
|
guildInfo,
|
|
channelId,
|
|
channelName: channelCtx.channelName,
|
|
channelSlug: channelCtx.channelSlug,
|
|
parentId: channelCtx.parentId,
|
|
parentName: channelCtx.parentName,
|
|
parentSlug: channelCtx.parentSlug,
|
|
scope: channelCtx.isThread ? "thread" : "channel",
|
|
});
|
|
const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`;
|
|
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
|
interaction: params.interaction,
|
|
guildInfo,
|
|
channelId,
|
|
rawGuildId,
|
|
channelCtx,
|
|
memberRoleIds,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: params.componentLabel,
|
|
unauthorizedReply,
|
|
allowNameMatching,
|
|
});
|
|
if (!memberAllowed) {
|
|
return;
|
|
}
|
|
|
|
const componentAllowed = await ensureComponentUserAllowed({
|
|
entry,
|
|
interaction: params.interaction,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: params.componentLabel,
|
|
unauthorizedReply,
|
|
allowNameMatching,
|
|
});
|
|
if (!componentAllowed) {
|
|
return;
|
|
}
|
|
const commandAuthorized = resolveComponentCommandAuthorized({
|
|
ctx: params.ctx,
|
|
interactionCtx,
|
|
channelConfig,
|
|
guildInfo,
|
|
allowNameMatching,
|
|
});
|
|
|
|
const consumed = resolveDiscordComponentEntry({
|
|
id: parsed.componentId,
|
|
consume: !entry.reusable,
|
|
});
|
|
if (!consumed) {
|
|
try {
|
|
await params.interaction.reply({
|
|
content: "This component has expired.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (consumed.kind === "modal-trigger") {
|
|
try {
|
|
await params.interaction.reply({
|
|
content: "This form is no longer available.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
const values = params.values ? mapSelectValues(consumed, params.values) : undefined;
|
|
if (consumed.callbackData) {
|
|
const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({
|
|
ctx: params.ctx,
|
|
interaction: params.interaction,
|
|
interactionCtx,
|
|
channelCtx,
|
|
isAuthorizedSender: commandAuthorized,
|
|
data: consumed.callbackData,
|
|
kind: consumed.kind === "select" ? "select" : "button",
|
|
values,
|
|
messageId: consumed.messageId ?? params.interaction.message?.id,
|
|
});
|
|
if (pluginDispatch === "handled") {
|
|
return;
|
|
}
|
|
}
|
|
const eventText = formatDiscordComponentEventText({
|
|
kind: consumed.kind === "select" ? "select" : "button",
|
|
label: consumed.label,
|
|
values,
|
|
});
|
|
|
|
try {
|
|
await params.interaction.reply({ content: "✓", ...replyOpts });
|
|
} catch (err) {
|
|
logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`);
|
|
}
|
|
|
|
await dispatchDiscordComponentEvent({
|
|
ctx: params.ctx,
|
|
interaction: params.interaction,
|
|
interactionCtx,
|
|
channelCtx,
|
|
guildInfo,
|
|
eventText,
|
|
replyToId: consumed.messageId ?? params.interaction.message?.id,
|
|
routeOverrides: {
|
|
sessionKey: consumed.sessionKey,
|
|
agentId: consumed.agentId,
|
|
accountId: consumed.accountId,
|
|
},
|
|
});
|
|
}
|
|
|
|
async function handleDiscordModalTrigger(params: {
|
|
ctx: AgentComponentContext;
|
|
interaction: ButtonInteraction;
|
|
data: ComponentData;
|
|
label: string;
|
|
}): Promise<void> {
|
|
const parsed = parseDiscordComponentData(
|
|
params.data,
|
|
resolveInteractionCustomId(params.interaction),
|
|
);
|
|
if (!parsed) {
|
|
logError(`${params.label}: failed to parse modal trigger data`);
|
|
try {
|
|
await params.interaction.reply({
|
|
content: "This button is no longer valid.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
const entry = resolveDiscordComponentEntry({ id: parsed.componentId, consume: false });
|
|
if (!entry || entry.kind !== "modal-trigger") {
|
|
try {
|
|
await params.interaction.reply({
|
|
content: "This button has expired.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
const modalId = entry.modalId ?? parsed.modalId;
|
|
if (!modalId) {
|
|
try {
|
|
await params.interaction.reply({
|
|
content: "This form is no longer available.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
const interactionCtx = await resolveInteractionContextWithDmAuth({
|
|
ctx: params.ctx,
|
|
interaction: params.interaction,
|
|
label: params.label,
|
|
componentLabel: "form",
|
|
defer: false,
|
|
});
|
|
if (!interactionCtx) {
|
|
return;
|
|
}
|
|
const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx;
|
|
const guildInfo = resolveDiscordGuildEntry({
|
|
guild: params.interaction.guild ?? undefined,
|
|
guildId: rawGuildId,
|
|
guildEntries: params.ctx.guildEntries,
|
|
});
|
|
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
|
const unauthorizedReply = "You are not authorized to use this form.";
|
|
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
|
interaction: params.interaction,
|
|
guildInfo,
|
|
channelId,
|
|
rawGuildId,
|
|
channelCtx,
|
|
memberRoleIds,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: "form",
|
|
unauthorizedReply,
|
|
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
|
});
|
|
if (!memberAllowed) {
|
|
return;
|
|
}
|
|
|
|
const componentAllowed = await ensureComponentUserAllowed({
|
|
entry,
|
|
interaction: params.interaction,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: "form",
|
|
unauthorizedReply,
|
|
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
|
});
|
|
if (!componentAllowed) {
|
|
return;
|
|
}
|
|
|
|
const consumed = resolveDiscordComponentEntry({
|
|
id: parsed.componentId,
|
|
consume: !entry.reusable,
|
|
});
|
|
if (!consumed) {
|
|
try {
|
|
await params.interaction.reply({
|
|
content: "This form has expired.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
const resolvedModalId = consumed.modalId ?? modalId;
|
|
const modalEntry = resolveDiscordModalEntry({ id: resolvedModalId, consume: false });
|
|
if (!modalEntry) {
|
|
try {
|
|
await params.interaction.reply({
|
|
content: "This form has expired.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await params.interaction.showModal(createDiscordFormModal(modalEntry));
|
|
} catch (err) {
|
|
logError(`${params.label}: failed to show modal: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
export class AgentComponentButton extends Button {
|
|
label = AGENT_BUTTON_KEY;
|
|
customId = `${AGENT_BUTTON_KEY}:seed=1`;
|
|
style = ButtonStyle.Primary;
|
|
private ctx: AgentComponentContext;
|
|
|
|
constructor(ctx: AgentComponentContext) {
|
|
super();
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
|
|
// Parse componentId from Carbon's parsed ComponentData
|
|
const parsed = parseAgentComponentData(data);
|
|
if (!parsed) {
|
|
logError("agent button: failed to parse component data");
|
|
try {
|
|
await interaction.reply({
|
|
content: "This button is no longer valid.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
const { componentId } = parsed;
|
|
|
|
const interactionCtx = await resolveInteractionContextWithDmAuth({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
label: "agent button",
|
|
componentLabel: "button",
|
|
});
|
|
if (!interactionCtx) {
|
|
return;
|
|
}
|
|
const {
|
|
channelId,
|
|
user,
|
|
username,
|
|
userId,
|
|
replyOpts,
|
|
rawGuildId,
|
|
isDirectMessage,
|
|
memberRoleIds,
|
|
} = interactionCtx;
|
|
|
|
// Check user allowlist before processing component interaction
|
|
// This prevents unauthorized users from injecting system events.
|
|
const allowed = await ensureAgentComponentInteractionAllowed({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
channelId,
|
|
rawGuildId,
|
|
memberRoleIds,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: "button",
|
|
unauthorizedReply: "You are not authorized to use this button.",
|
|
});
|
|
if (!allowed) {
|
|
return;
|
|
}
|
|
const { parentId } = allowed;
|
|
|
|
const route = resolveAgentComponentRoute({
|
|
ctx: this.ctx,
|
|
rawGuildId,
|
|
memberRoleIds,
|
|
isDirectMessage,
|
|
userId,
|
|
channelId,
|
|
parentId,
|
|
});
|
|
|
|
const eventText = `[Discord component: ${componentId} clicked by ${username} (${userId})]`;
|
|
|
|
logDebug(`agent button: enqueuing event for channel ${channelId}: ${eventText}`);
|
|
|
|
enqueueSystemEvent(eventText, {
|
|
sessionKey: route.sessionKey,
|
|
contextKey: `discord:agent-button:${channelId}:${componentId}:${userId}`,
|
|
});
|
|
|
|
await ackComponentInteraction({ interaction, replyOpts, label: "agent button" });
|
|
}
|
|
}
|
|
|
|
export class AgentSelectMenu extends StringSelectMenu {
|
|
customId = `${AGENT_SELECT_KEY}:seed=1`;
|
|
options: APIStringSelectComponent["options"] = [];
|
|
private ctx: AgentComponentContext;
|
|
|
|
constructor(ctx: AgentComponentContext) {
|
|
super();
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
async run(interaction: StringSelectMenuInteraction, data: ComponentData): Promise<void> {
|
|
// Parse componentId from Carbon's parsed ComponentData
|
|
const parsed = parseAgentComponentData(data);
|
|
if (!parsed) {
|
|
logError("agent select: failed to parse component data");
|
|
try {
|
|
await interaction.reply({
|
|
content: "This select menu is no longer valid.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
const { componentId } = parsed;
|
|
|
|
const interactionCtx = await resolveInteractionContextWithDmAuth({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
label: "agent select",
|
|
componentLabel: "select menu",
|
|
});
|
|
if (!interactionCtx) {
|
|
return;
|
|
}
|
|
const {
|
|
channelId,
|
|
user,
|
|
username,
|
|
userId,
|
|
replyOpts,
|
|
rawGuildId,
|
|
isDirectMessage,
|
|
memberRoleIds,
|
|
} = interactionCtx;
|
|
|
|
// Check user allowlist before processing component interaction.
|
|
const allowed = await ensureAgentComponentInteractionAllowed({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
channelId,
|
|
rawGuildId,
|
|
memberRoleIds,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: "select",
|
|
unauthorizedReply: "You are not authorized to use this select menu.",
|
|
});
|
|
if (!allowed) {
|
|
return;
|
|
}
|
|
const { parentId } = allowed;
|
|
|
|
// Extract selected values
|
|
const values = interaction.values ?? [];
|
|
const valuesText = values.length > 0 ? ` (selected: ${values.join(", ")})` : "";
|
|
|
|
const route = resolveAgentComponentRoute({
|
|
ctx: this.ctx,
|
|
rawGuildId,
|
|
memberRoleIds,
|
|
isDirectMessage,
|
|
userId,
|
|
channelId,
|
|
parentId,
|
|
});
|
|
|
|
const eventText = `[Discord select menu: ${componentId} interacted by ${username} (${userId})${valuesText}]`;
|
|
|
|
logDebug(`agent select: enqueuing event for channel ${channelId}: ${eventText}`);
|
|
|
|
enqueueSystemEvent(eventText, {
|
|
sessionKey: route.sessionKey,
|
|
contextKey: `discord:agent-select:${channelId}:${componentId}:${userId}`,
|
|
});
|
|
|
|
await ackComponentInteraction({ interaction, replyOpts, label: "agent select" });
|
|
}
|
|
}
|
|
|
|
class DiscordComponentButton extends Button {
|
|
label = "component";
|
|
customId = "__openclaw_discord_component_button_wildcard__";
|
|
style = ButtonStyle.Primary;
|
|
customIdParser = parseDiscordComponentCustomIdForCarbon;
|
|
private ctx: AgentComponentContext;
|
|
|
|
constructor(ctx: AgentComponentContext) {
|
|
super();
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
|
|
const parsed = parseDiscordComponentData(data, resolveInteractionCustomId(interaction));
|
|
if (parsed?.modalId) {
|
|
await handleDiscordModalTrigger({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
data,
|
|
label: "discord component modal",
|
|
});
|
|
return;
|
|
}
|
|
await handleDiscordComponentEvent({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
data,
|
|
componentLabel: "button",
|
|
label: "discord component button",
|
|
});
|
|
}
|
|
}
|
|
|
|
class DiscordComponentStringSelect extends StringSelectMenu {
|
|
customId = "__openclaw_discord_component_string_select_wildcard__";
|
|
options: APIStringSelectComponent["options"] = [];
|
|
customIdParser = parseDiscordComponentCustomIdForCarbon;
|
|
private ctx: AgentComponentContext;
|
|
|
|
constructor(ctx: AgentComponentContext) {
|
|
super();
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
async run(interaction: StringSelectMenuInteraction, data: ComponentData): Promise<void> {
|
|
await handleDiscordComponentEvent({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
data,
|
|
componentLabel: "select menu",
|
|
label: "discord component select",
|
|
values: interaction.values ?? [],
|
|
});
|
|
}
|
|
}
|
|
|
|
class DiscordComponentUserSelect extends UserSelectMenu {
|
|
customId = "__openclaw_discord_component_user_select_wildcard__";
|
|
customIdParser = parseDiscordComponentCustomIdForCarbon;
|
|
private ctx: AgentComponentContext;
|
|
|
|
constructor(ctx: AgentComponentContext) {
|
|
super();
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
async run(interaction: UserSelectMenuInteraction, data: ComponentData): Promise<void> {
|
|
await handleDiscordComponentEvent({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
data,
|
|
componentLabel: "user select",
|
|
label: "discord component user select",
|
|
values: interaction.values ?? [],
|
|
});
|
|
}
|
|
}
|
|
|
|
class DiscordComponentRoleSelect extends RoleSelectMenu {
|
|
customId = "__openclaw_discord_component_role_select_wildcard__";
|
|
customIdParser = parseDiscordComponentCustomIdForCarbon;
|
|
private ctx: AgentComponentContext;
|
|
|
|
constructor(ctx: AgentComponentContext) {
|
|
super();
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
async run(interaction: RoleSelectMenuInteraction, data: ComponentData): Promise<void> {
|
|
await handleDiscordComponentEvent({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
data,
|
|
componentLabel: "role select",
|
|
label: "discord component role select",
|
|
values: interaction.values ?? [],
|
|
});
|
|
}
|
|
}
|
|
|
|
class DiscordComponentMentionableSelect extends MentionableSelectMenu {
|
|
customId = "__openclaw_discord_component_mentionable_select_wildcard__";
|
|
customIdParser = parseDiscordComponentCustomIdForCarbon;
|
|
private ctx: AgentComponentContext;
|
|
|
|
constructor(ctx: AgentComponentContext) {
|
|
super();
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
async run(interaction: MentionableSelectMenuInteraction, data: ComponentData): Promise<void> {
|
|
await handleDiscordComponentEvent({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
data,
|
|
componentLabel: "mentionable select",
|
|
label: "discord component mentionable select",
|
|
values: interaction.values ?? [],
|
|
});
|
|
}
|
|
}
|
|
|
|
class DiscordComponentChannelSelect extends ChannelSelectMenu {
|
|
customId = "__openclaw_discord_component_channel_select_wildcard__";
|
|
customIdParser = parseDiscordComponentCustomIdForCarbon;
|
|
private ctx: AgentComponentContext;
|
|
|
|
constructor(ctx: AgentComponentContext) {
|
|
super();
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
async run(interaction: ChannelSelectMenuInteraction, data: ComponentData): Promise<void> {
|
|
await handleDiscordComponentEvent({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
data,
|
|
componentLabel: "channel select",
|
|
label: "discord component channel select",
|
|
values: interaction.values ?? [],
|
|
});
|
|
}
|
|
}
|
|
|
|
class DiscordComponentModal extends Modal {
|
|
title = "OpenClaw form";
|
|
customId = "__openclaw_discord_component_modal_wildcard__";
|
|
components = [];
|
|
customIdParser = parseDiscordModalCustomIdForCarbon;
|
|
private ctx: AgentComponentContext;
|
|
|
|
constructor(ctx: AgentComponentContext) {
|
|
super();
|
|
this.ctx = ctx;
|
|
}
|
|
|
|
async run(interaction: ModalInteraction, data: ComponentData): Promise<void> {
|
|
const modalId = parseDiscordModalId(data, resolveInteractionCustomId(interaction));
|
|
if (!modalId) {
|
|
logError("discord component modal: missing modal id");
|
|
try {
|
|
await interaction.reply({
|
|
content: "This form is no longer valid.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
const modalEntry = resolveDiscordModalEntry({ id: modalId, consume: false });
|
|
if (!modalEntry) {
|
|
try {
|
|
await interaction.reply({
|
|
content: "This form has expired.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
const interactionCtx = await resolveInteractionContextWithDmAuth({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
label: "discord component modal",
|
|
componentLabel: "form",
|
|
defer: false,
|
|
});
|
|
if (!interactionCtx) {
|
|
return;
|
|
}
|
|
const { channelId, user, replyOpts, rawGuildId, memberRoleIds } = interactionCtx;
|
|
const guildInfo = resolveDiscordGuildEntry({
|
|
guild: interaction.guild ?? undefined,
|
|
guildId: rawGuildId,
|
|
guildEntries: this.ctx.guildEntries,
|
|
});
|
|
const channelCtx = resolveDiscordChannelContext(interaction);
|
|
const allowNameMatching = isDangerousNameMatchingEnabled(this.ctx.discordConfig);
|
|
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
|
guildInfo,
|
|
channelId,
|
|
channelName: channelCtx.channelName,
|
|
channelSlug: channelCtx.channelSlug,
|
|
parentId: channelCtx.parentId,
|
|
parentName: channelCtx.parentName,
|
|
parentSlug: channelCtx.parentSlug,
|
|
scope: channelCtx.isThread ? "thread" : "channel",
|
|
});
|
|
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
|
interaction,
|
|
guildInfo,
|
|
channelId,
|
|
rawGuildId,
|
|
channelCtx,
|
|
memberRoleIds,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: "form",
|
|
unauthorizedReply: "You are not authorized to use this form.",
|
|
allowNameMatching,
|
|
});
|
|
if (!memberAllowed) {
|
|
return;
|
|
}
|
|
|
|
const modalAllowed = await ensureComponentUserAllowed({
|
|
entry: {
|
|
id: modalEntry.id,
|
|
kind: "button",
|
|
label: modalEntry.title,
|
|
allowedUsers: modalEntry.allowedUsers,
|
|
},
|
|
interaction,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: "form",
|
|
unauthorizedReply: "You are not authorized to use this form.",
|
|
allowNameMatching,
|
|
});
|
|
if (!modalAllowed) {
|
|
return;
|
|
}
|
|
const commandAuthorized = resolveComponentCommandAuthorized({
|
|
ctx: this.ctx,
|
|
interactionCtx,
|
|
channelConfig,
|
|
guildInfo,
|
|
allowNameMatching,
|
|
});
|
|
|
|
const consumed = resolveDiscordModalEntry({
|
|
id: modalId,
|
|
consume: !modalEntry.reusable,
|
|
});
|
|
if (!consumed) {
|
|
try {
|
|
await interaction.reply({
|
|
content: "This form has expired.",
|
|
ephemeral: true,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (consumed.callbackData) {
|
|
const fields = consumed.fields.map((field) => ({
|
|
id: field.id,
|
|
name: field.name,
|
|
values: resolveModalFieldValues(field, interaction),
|
|
}));
|
|
const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
interactionCtx,
|
|
channelCtx,
|
|
isAuthorizedSender: commandAuthorized,
|
|
data: consumed.callbackData,
|
|
kind: "modal",
|
|
fields,
|
|
messageId: consumed.messageId,
|
|
});
|
|
if (pluginDispatch === "handled") {
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await interaction.acknowledge();
|
|
} catch (err) {
|
|
logError(`discord component modal: failed to acknowledge: ${String(err)}`);
|
|
}
|
|
|
|
const eventText = formatModalSubmissionText(consumed, interaction);
|
|
await dispatchDiscordComponentEvent({
|
|
ctx: this.ctx,
|
|
interaction,
|
|
interactionCtx,
|
|
channelCtx,
|
|
guildInfo,
|
|
eventText,
|
|
replyToId: consumed.messageId,
|
|
routeOverrides: {
|
|
sessionKey: consumed.sessionKey,
|
|
agentId: consumed.agentId,
|
|
accountId: consumed.accountId,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
export function createAgentComponentButton(ctx: AgentComponentContext): Button {
|
|
return new AgentComponentButton(ctx);
|
|
}
|
|
|
|
export function createAgentSelectMenu(ctx: AgentComponentContext): StringSelectMenu {
|
|
return new AgentSelectMenu(ctx);
|
|
}
|
|
|
|
export function createDiscordComponentButton(ctx: AgentComponentContext): Button {
|
|
return new DiscordComponentButton(ctx);
|
|
}
|
|
|
|
export function createDiscordComponentStringSelect(ctx: AgentComponentContext): StringSelectMenu {
|
|
return new DiscordComponentStringSelect(ctx);
|
|
}
|
|
|
|
export function createDiscordComponentUserSelect(ctx: AgentComponentContext): UserSelectMenu {
|
|
return new DiscordComponentUserSelect(ctx);
|
|
}
|
|
|
|
export function createDiscordComponentRoleSelect(ctx: AgentComponentContext): RoleSelectMenu {
|
|
return new DiscordComponentRoleSelect(ctx);
|
|
}
|
|
|
|
export function createDiscordComponentMentionableSelect(
|
|
ctx: AgentComponentContext,
|
|
): MentionableSelectMenu {
|
|
return new DiscordComponentMentionableSelect(ctx);
|
|
}
|
|
|
|
export function createDiscordComponentChannelSelect(ctx: AgentComponentContext): ChannelSelectMenu {
|
|
return new DiscordComponentChannelSelect(ctx);
|
|
}
|
|
|
|
export function createDiscordComponentModal(ctx: AgentComponentContext): Modal {
|
|
return new DiscordComponentModal(ctx);
|
|
}
|