mirror of
https://github.com/moltbot/moltbot.git
synced 2026-03-07 22:44:16 +00:00
* fix(channels): default allowFrom to id-only; add dangerous name opt-in * docs(security): align channel allowFrom docs with id-only default
1669 lines
48 KiB
TypeScript
1669 lines
48 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 UserSelectMenuInteraction,
|
|
} from "@buape/carbon";
|
|
import type { APIStringSelectComponent } from "discord-api-types/v10";
|
|
import { ButtonStyle, ChannelType } from "discord-api-types/v10";
|
|
import { resolveHumanDelayConfig } from "../../agents/identity.js";
|
|
import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js";
|
|
import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js";
|
|
import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js";
|
|
import { dispatchReplyWithBufferedBlockDispatcher } from "../../auto-reply/reply/provider-dispatcher.js";
|
|
import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js";
|
|
import { createReplyPrefixOptions } from "../../channels/reply-prefix.js";
|
|
import { recordInboundSession } from "../../channels/session.js";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import { resolveMarkdownTableMode } from "../../config/markdown-tables.js";
|
|
import { readSessionUpdatedAt, resolveStorePath } from "../../config/sessions.js";
|
|
import type { DiscordAccountConfig } from "../../config/types.discord.js";
|
|
import { logVerbose } from "../../globals.js";
|
|
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
|
import { logDebug, logError } from "../../logger.js";
|
|
import { buildPairingReply } from "../../pairing/pairing-messages.js";
|
|
import {
|
|
readChannelAllowFromStore,
|
|
upsertChannelPairingRequest,
|
|
} from "../../pairing/pairing-store.js";
|
|
import { resolveAgentRoute } from "../../routing/resolve-route.js";
|
|
import { createNonExitingRuntime, type RuntimeEnv } from "../../runtime.js";
|
|
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
|
|
import {
|
|
createDiscordFormModal,
|
|
formatDiscordComponentEventText,
|
|
parseDiscordComponentCustomId,
|
|
parseDiscordComponentCustomIdForCarbon,
|
|
parseDiscordModalCustomId,
|
|
parseDiscordModalCustomIdForCarbon,
|
|
type DiscordComponentEntry,
|
|
type DiscordModalEntry,
|
|
} from "../components.js";
|
|
import {
|
|
type DiscordGuildEntryResolved,
|
|
normalizeDiscordAllowList,
|
|
normalizeDiscordSlug,
|
|
resolveDiscordAllowListMatch,
|
|
resolveDiscordChannelConfigWithFallback,
|
|
resolveDiscordGuildEntry,
|
|
resolveDiscordMemberAccessState,
|
|
resolveDiscordOwnerAllowFrom,
|
|
} from "./allow-list.js";
|
|
import { formatDiscordUserTag } from "./format.js";
|
|
import { buildDirectLabel, buildGuildLabel } from "./reply-context.js";
|
|
import { deliverDiscordReply } from "./reply-delivery.js";
|
|
import { sendTyping } from "./typing.js";
|
|
|
|
const AGENT_BUTTON_KEY = "agent";
|
|
const AGENT_SELECT_KEY = "agentsel";
|
|
|
|
type DiscordUser = Parameters<typeof formatDiscordUserTag>[0];
|
|
|
|
type AgentComponentMessageInteraction =
|
|
| ButtonInteraction
|
|
| StringSelectMenuInteraction
|
|
| RoleSelectMenuInteraction
|
|
| UserSelectMenuInteraction
|
|
| MentionableSelectMenuInteraction
|
|
| ChannelSelectMenuInteraction;
|
|
|
|
type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction;
|
|
|
|
type ComponentInteractionContext = NonNullable<
|
|
Awaited<ReturnType<typeof resolveComponentInteractionContext>>
|
|
>;
|
|
|
|
type DiscordChannelContext = {
|
|
channelName: string | undefined;
|
|
channelSlug: string;
|
|
channelType: number | undefined;
|
|
isThread: boolean;
|
|
parentId: string | undefined;
|
|
parentName: string | undefined;
|
|
parentSlug: string;
|
|
};
|
|
|
|
function resolveAgentComponentRoute(params: {
|
|
ctx: AgentComponentContext;
|
|
rawGuildId: string | undefined;
|
|
memberRoleIds: string[];
|
|
isDirectMessage: boolean;
|
|
userId: string;
|
|
channelId: string;
|
|
parentId: string | undefined;
|
|
}) {
|
|
return resolveAgentRoute({
|
|
cfg: params.ctx.cfg,
|
|
channel: "discord",
|
|
accountId: params.ctx.accountId,
|
|
guildId: params.rawGuildId,
|
|
memberRoleIds: params.memberRoleIds,
|
|
peer: {
|
|
kind: params.isDirectMessage ? "direct" : "channel",
|
|
id: params.isDirectMessage ? params.userId : params.channelId,
|
|
},
|
|
parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
|
|
});
|
|
}
|
|
|
|
async function ackComponentInteraction(params: {
|
|
interaction: AgentComponentInteraction;
|
|
replyOpts: { ephemeral?: boolean };
|
|
label: string;
|
|
}) {
|
|
try {
|
|
await params.interaction.reply({
|
|
content: "✓",
|
|
...params.replyOpts,
|
|
});
|
|
} catch (err) {
|
|
logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`);
|
|
}
|
|
}
|
|
|
|
function resolveDiscordChannelContext(
|
|
interaction: AgentComponentInteraction,
|
|
): DiscordChannelContext {
|
|
const channel = interaction.channel;
|
|
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
|
|
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
|
const channelType = channel && "type" in channel ? (channel.type as number) : undefined;
|
|
const isThread = isThreadChannelType(channelType);
|
|
|
|
let parentId: string | undefined;
|
|
let parentName: string | undefined;
|
|
let parentSlug = "";
|
|
if (isThread && channel && "parentId" in channel) {
|
|
parentId = (channel.parentId as string) ?? undefined;
|
|
if ("parent" in channel) {
|
|
const parent = (channel as { parent?: { name?: string } }).parent;
|
|
if (parent?.name) {
|
|
parentName = parent.name;
|
|
parentSlug = normalizeDiscordSlug(parentName);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
|
|
}
|
|
|
|
async function resolveComponentInteractionContext(params: {
|
|
interaction: AgentComponentInteraction;
|
|
label: string;
|
|
defer?: boolean;
|
|
}): Promise<{
|
|
channelId: string;
|
|
user: DiscordUser;
|
|
username: string;
|
|
userId: string;
|
|
replyOpts: { ephemeral?: boolean };
|
|
rawGuildId: string | undefined;
|
|
isDirectMessage: boolean;
|
|
memberRoleIds: string[];
|
|
} | null> {
|
|
const { interaction, label } = params;
|
|
|
|
// Use interaction's actual channel_id (trusted source from Discord)
|
|
// This prevents channel spoofing attacks
|
|
const channelId = interaction.rawData.channel_id;
|
|
if (!channelId) {
|
|
logError(`${label}: missing channel_id in interaction`);
|
|
return null;
|
|
}
|
|
|
|
const user = interaction.user;
|
|
if (!user) {
|
|
logError(`${label}: missing user in interaction`);
|
|
return null;
|
|
}
|
|
|
|
const shouldDefer = params.defer !== false && "defer" in interaction;
|
|
let didDefer = false;
|
|
// Defer immediately to satisfy Discord's 3-second interaction ACK requirement.
|
|
// We use an ephemeral deferred reply so subsequent interaction.reply() calls
|
|
// can safely edit the original deferred response.
|
|
if (shouldDefer) {
|
|
try {
|
|
await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true });
|
|
didDefer = true;
|
|
} catch (err) {
|
|
logError(`${label}: failed to defer interaction: ${String(err)}`);
|
|
}
|
|
}
|
|
const replyOpts = didDefer ? {} : { ephemeral: true };
|
|
|
|
const username = formatUsername(user);
|
|
const userId = user.id;
|
|
|
|
// P1 FIX: Use rawData.guild_id as source of truth - interaction.guild can be null
|
|
// when guild is not cached even though guild_id is present in rawData
|
|
const rawGuildId = interaction.rawData.guild_id;
|
|
const isDirectMessage = !rawGuildId;
|
|
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
|
|
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
|
|
: [];
|
|
|
|
return {
|
|
channelId,
|
|
user,
|
|
username,
|
|
userId,
|
|
replyOpts,
|
|
rawGuildId,
|
|
isDirectMessage,
|
|
memberRoleIds,
|
|
};
|
|
}
|
|
|
|
async function ensureGuildComponentMemberAllowed(params: {
|
|
interaction: AgentComponentInteraction;
|
|
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
|
|
channelId: string;
|
|
rawGuildId: string | undefined;
|
|
channelCtx: DiscordChannelContext;
|
|
memberRoleIds: string[];
|
|
user: DiscordUser;
|
|
replyOpts: { ephemeral?: boolean };
|
|
componentLabel: string;
|
|
unauthorizedReply: string;
|
|
allowNameMatching: boolean;
|
|
}): Promise<boolean> {
|
|
const {
|
|
interaction,
|
|
guildInfo,
|
|
channelId,
|
|
rawGuildId,
|
|
channelCtx,
|
|
memberRoleIds,
|
|
user,
|
|
replyOpts,
|
|
componentLabel,
|
|
unauthorizedReply,
|
|
} = params;
|
|
|
|
if (!rawGuildId) {
|
|
return true;
|
|
}
|
|
|
|
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 } = resolveDiscordMemberAccessState({
|
|
channelConfig,
|
|
guildInfo,
|
|
memberRoleIds,
|
|
sender: {
|
|
id: user.id,
|
|
name: user.username,
|
|
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
|
|
},
|
|
allowNameMatching: params.allowNameMatching,
|
|
});
|
|
if (memberAllowed) {
|
|
return true;
|
|
}
|
|
|
|
logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`);
|
|
try {
|
|
await interaction.reply({
|
|
content: unauthorizedReply,
|
|
...replyOpts,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function ensureComponentUserAllowed(params: {
|
|
entry: DiscordComponentEntry;
|
|
interaction: AgentComponentInteraction;
|
|
user: DiscordUser;
|
|
replyOpts: { ephemeral?: boolean };
|
|
componentLabel: string;
|
|
unauthorizedReply: string;
|
|
allowNameMatching: boolean;
|
|
}): Promise<boolean> {
|
|
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
|
|
"discord:",
|
|
"user:",
|
|
"pk:",
|
|
]);
|
|
if (!allowList) {
|
|
return true;
|
|
}
|
|
const match = resolveDiscordAllowListMatch({
|
|
allowList,
|
|
candidate: {
|
|
id: params.user.id,
|
|
name: params.user.username,
|
|
tag: formatDiscordUserTag(params.user),
|
|
},
|
|
allowNameMatching: params.allowNameMatching,
|
|
});
|
|
if (match.allowed) {
|
|
return true;
|
|
}
|
|
|
|
logVerbose(
|
|
`discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`,
|
|
);
|
|
try {
|
|
await params.interaction.reply({
|
|
content: params.unauthorizedReply,
|
|
...params.replyOpts,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function ensureAgentComponentInteractionAllowed(params: {
|
|
ctx: AgentComponentContext;
|
|
interaction: AgentComponentInteraction;
|
|
channelId: string;
|
|
rawGuildId: string | undefined;
|
|
memberRoleIds: string[];
|
|
user: DiscordUser;
|
|
replyOpts: { ephemeral?: boolean };
|
|
componentLabel: string;
|
|
unauthorizedReply: string;
|
|
}): Promise<{ parentId: string | undefined } | null> {
|
|
const guildInfo = resolveDiscordGuildEntry({
|
|
guild: params.interaction.guild ?? undefined,
|
|
guildEntries: params.ctx.guildEntries,
|
|
});
|
|
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
|
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
|
interaction: params.interaction,
|
|
guildInfo,
|
|
channelId: params.channelId,
|
|
rawGuildId: params.rawGuildId,
|
|
channelCtx,
|
|
memberRoleIds: params.memberRoleIds,
|
|
user: params.user,
|
|
replyOpts: params.replyOpts,
|
|
componentLabel: params.componentLabel,
|
|
unauthorizedReply: params.unauthorizedReply,
|
|
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
|
});
|
|
if (!memberAllowed) {
|
|
return null;
|
|
}
|
|
return { parentId: channelCtx.parentId };
|
|
}
|
|
|
|
export type AgentComponentContext = {
|
|
cfg: OpenClawConfig;
|
|
accountId: string;
|
|
discordConfig?: DiscordAccountConfig;
|
|
runtime?: RuntimeEnv;
|
|
token?: string;
|
|
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
|
/** DM allowlist (from allowFrom config; legacy: dm.allowFrom) */
|
|
allowFrom?: string[];
|
|
/** DM policy (default: "pairing") */
|
|
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
|
};
|
|
|
|
/**
|
|
* Build agent button custom ID: agent:componentId=<id>
|
|
* The channelId is NOT embedded in customId - we use interaction.rawData.channel_id instead
|
|
* to prevent channel spoofing attacks.
|
|
*
|
|
* Carbon's customIdParser parses "key:arg1=value1;arg2=value2" into { arg1: value1, arg2: value2 }
|
|
*/
|
|
export function buildAgentButtonCustomId(componentId: string): string {
|
|
return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`;
|
|
}
|
|
|
|
/**
|
|
* Build agent select menu custom ID: agentsel:componentId=<id>
|
|
*/
|
|
export function buildAgentSelectCustomId(componentId: string): string {
|
|
return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`;
|
|
}
|
|
|
|
/**
|
|
* Parse agent component data from Carbon's parsed ComponentData
|
|
* Carbon parses "key:componentId=xxx" into { componentId: "xxx" }
|
|
*/
|
|
function parseAgentComponentData(data: ComponentData): {
|
|
componentId: string;
|
|
} | null {
|
|
if (!data || typeof data !== "object") {
|
|
return null;
|
|
}
|
|
const componentId =
|
|
typeof data.componentId === "string"
|
|
? decodeURIComponent(data.componentId)
|
|
: typeof data.componentId === "number"
|
|
? String(data.componentId)
|
|
: null;
|
|
if (!componentId) {
|
|
return null;
|
|
}
|
|
return { componentId };
|
|
}
|
|
|
|
function formatUsername(user: { username: string; discriminator?: string | null }): string {
|
|
if (user.discriminator && user.discriminator !== "0") {
|
|
return `${user.username}#${user.discriminator}`;
|
|
}
|
|
return user.username;
|
|
}
|
|
|
|
/**
|
|
* Check if a channel type is a thread type
|
|
*/
|
|
function isThreadChannelType(channelType: number | undefined): boolean {
|
|
return (
|
|
channelType === ChannelType.PublicThread ||
|
|
channelType === ChannelType.PrivateThread ||
|
|
channelType === ChannelType.AnnouncementThread
|
|
);
|
|
}
|
|
|
|
async function ensureDmComponentAuthorized(params: {
|
|
ctx: AgentComponentContext;
|
|
interaction: AgentComponentInteraction;
|
|
user: DiscordUser;
|
|
componentLabel: string;
|
|
replyOpts: { ephemeral?: boolean };
|
|
}): Promise<boolean> {
|
|
const { ctx, interaction, user, componentLabel, replyOpts } = params;
|
|
const dmPolicy = ctx.dmPolicy ?? "pairing";
|
|
if (dmPolicy === "disabled") {
|
|
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
|
|
try {
|
|
await interaction.reply({
|
|
content: "DM interactions are disabled.",
|
|
...replyOpts,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return false;
|
|
}
|
|
if (dmPolicy === "open") {
|
|
return true;
|
|
}
|
|
|
|
const storeAllowFrom =
|
|
dmPolicy === "allowlist" ? [] : await readChannelAllowFromStore("discord").catch(() => []);
|
|
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
|
|
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
|
|
const allowMatch = allowList
|
|
? resolveDiscordAllowListMatch({
|
|
allowList,
|
|
candidate: {
|
|
id: user.id,
|
|
name: user.username,
|
|
tag: formatDiscordUserTag(user),
|
|
},
|
|
allowNameMatching: ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
|
})
|
|
: { allowed: false };
|
|
if (allowMatch.allowed) {
|
|
return true;
|
|
}
|
|
|
|
if (dmPolicy === "pairing") {
|
|
const { code, created } = await upsertChannelPairingRequest({
|
|
channel: "discord",
|
|
id: user.id,
|
|
meta: {
|
|
tag: formatDiscordUserTag(user),
|
|
name: user.username,
|
|
},
|
|
});
|
|
try {
|
|
await interaction.reply({
|
|
content: created
|
|
? buildPairingReply({
|
|
channel: "discord",
|
|
idLine: `Your Discord user id: ${user.id}`,
|
|
code,
|
|
})
|
|
: "Pairing already requested. Ask the bot owner to approve your code.",
|
|
...replyOpts,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return false;
|
|
}
|
|
|
|
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
|
|
try {
|
|
await interaction.reply({
|
|
content: `You are not authorized to use this ${componentLabel}.`,
|
|
...replyOpts,
|
|
});
|
|
} catch {
|
|
// Interaction may have expired
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function resolveInteractionContextWithDmAuth(params: {
|
|
ctx: AgentComponentContext;
|
|
interaction: AgentComponentInteraction;
|
|
label: string;
|
|
componentLabel: string;
|
|
defer?: boolean;
|
|
}): Promise<ComponentInteractionContext | null> {
|
|
const interactionCtx = await resolveComponentInteractionContext({
|
|
interaction: params.interaction,
|
|
label: params.label,
|
|
defer: params.defer,
|
|
});
|
|
if (!interactionCtx) {
|
|
return null;
|
|
}
|
|
if (interactionCtx.isDirectMessage) {
|
|
const authorized = await ensureDmComponentAuthorized({
|
|
ctx: params.ctx,
|
|
interaction: params.interaction,
|
|
user: interactionCtx.user,
|
|
componentLabel: params.componentLabel,
|
|
replyOpts: interactionCtx.replyOpts,
|
|
});
|
|
if (!authorized) {
|
|
return null;
|
|
}
|
|
}
|
|
return interactionCtx;
|
|
}
|
|
|
|
function normalizeComponentId(value: unknown): string | undefined {
|
|
if (typeof value === "string") {
|
|
const trimmed = value.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
if (typeof value === "number" && Number.isFinite(value)) {
|
|
return String(value);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseDiscordComponentData(
|
|
data: ComponentData,
|
|
customId?: string,
|
|
): { componentId: string; modalId?: string } | null {
|
|
if (!data || typeof data !== "object") {
|
|
return null;
|
|
}
|
|
const rawComponentId =
|
|
"cid" in data
|
|
? (data as { cid?: unknown }).cid
|
|
: (data as { componentId?: unknown }).componentId;
|
|
const rawModalId =
|
|
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
|
|
let componentId = normalizeComponentId(rawComponentId);
|
|
let modalId = normalizeComponentId(rawModalId);
|
|
if (!componentId && customId) {
|
|
const parsed = parseDiscordComponentCustomId(customId);
|
|
if (parsed) {
|
|
componentId = parsed.componentId;
|
|
modalId = parsed.modalId;
|
|
}
|
|
}
|
|
if (!componentId) {
|
|
return null;
|
|
}
|
|
return { componentId, modalId };
|
|
}
|
|
|
|
function parseDiscordModalId(data: ComponentData, customId?: string): string | null {
|
|
if (data && typeof data === "object") {
|
|
const rawModalId =
|
|
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
|
|
const modalId = normalizeComponentId(rawModalId);
|
|
if (modalId) {
|
|
return modalId;
|
|
}
|
|
}
|
|
if (customId) {
|
|
return parseDiscordModalCustomId(customId);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveInteractionCustomId(interaction: AgentComponentInteraction): string | undefined {
|
|
if (!interaction?.rawData || typeof interaction.rawData !== "object") {
|
|
return undefined;
|
|
}
|
|
if (!("data" in interaction.rawData)) {
|
|
return undefined;
|
|
}
|
|
const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data;
|
|
const customId = data?.custom_id;
|
|
if (typeof customId !== "string") {
|
|
return undefined;
|
|
}
|
|
const trimmed = customId.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function mapOptionLabels(
|
|
options: Array<{ value: string; label: string }> | undefined,
|
|
values: string[],
|
|
) {
|
|
if (!options || options.length === 0) {
|
|
return values;
|
|
}
|
|
const map = new Map(options.map((option) => [option.value, option.label]));
|
|
return values.map((value) => map.get(value) ?? value);
|
|
}
|
|
|
|
function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] {
|
|
if (entry.selectType === "string") {
|
|
return mapOptionLabels(entry.options, values);
|
|
}
|
|
if (entry.selectType === "user") {
|
|
return values.map((value) => `user:${value}`);
|
|
}
|
|
if (entry.selectType === "role") {
|
|
return values.map((value) => `role:${value}`);
|
|
}
|
|
if (entry.selectType === "mentionable") {
|
|
return values.map((value) => `mentionable:${value}`);
|
|
}
|
|
if (entry.selectType === "channel") {
|
|
return values.map((value) => `channel:${value}`);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function resolveModalFieldValues(
|
|
field: DiscordModalEntry["fields"][number],
|
|
interaction: ModalInteraction,
|
|
): string[] {
|
|
const fields = interaction.fields;
|
|
const optionLabels = field.options?.map((option) => ({
|
|
value: option.value,
|
|
label: option.label,
|
|
}));
|
|
const required = field.required === true;
|
|
try {
|
|
switch (field.type) {
|
|
case "text": {
|
|
const value = required ? fields.getText(field.id, true) : fields.getText(field.id);
|
|
return value ? [value] : [];
|
|
}
|
|
case "select":
|
|
case "checkbox":
|
|
case "radio": {
|
|
const values = required
|
|
? fields.getStringSelect(field.id, true)
|
|
: (fields.getStringSelect(field.id) ?? []);
|
|
return mapOptionLabels(optionLabels, values);
|
|
}
|
|
case "role-select": {
|
|
try {
|
|
const roles = required
|
|
? fields.getRoleSelect(field.id, true)
|
|
: (fields.getRoleSelect(field.id) ?? []);
|
|
return roles.map((role) => role.name ?? role.id);
|
|
} catch {
|
|
const values = required
|
|
? fields.getStringSelect(field.id, true)
|
|
: (fields.getStringSelect(field.id) ?? []);
|
|
return values;
|
|
}
|
|
}
|
|
case "user-select": {
|
|
const users = required
|
|
? fields.getUserSelect(field.id, true)
|
|
: (fields.getUserSelect(field.id) ?? []);
|
|
return users.map((user) => formatDiscordUserTag(user));
|
|
}
|
|
default:
|
|
return [];
|
|
}
|
|
} catch (err) {
|
|
logError(`agent modal: failed to read field ${field.id}: ${String(err)}`);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function formatModalSubmissionText(
|
|
entry: DiscordModalEntry,
|
|
interaction: ModalInteraction,
|
|
): string {
|
|
const lines: string[] = [`Form "${entry.title}" submitted.`];
|
|
for (const field of entry.fields) {
|
|
const values = resolveModalFieldValues(field, interaction);
|
|
if (values.length === 0) {
|
|
continue;
|
|
}
|
|
lines.push(`- ${field.label}: ${values.join(", ")}`);
|
|
}
|
|
if (lines.length === 1) {
|
|
lines.push("- (no values)");
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
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 groupSystemPrompt = channelConfig?.systemPrompt?.trim() || undefined;
|
|
const ownerAllowFrom = resolveDiscordOwnerAllowFrom({
|
|
channelConfig,
|
|
guildInfo,
|
|
sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: senderTag },
|
|
allowNameMatching: ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
|
});
|
|
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: true,
|
|
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,
|
|
}
|
|
: undefined,
|
|
onRecordError: (err) => {
|
|
logVerbose(`discord: failed updating component session meta: ${String(err)}`);
|
|
},
|
|
});
|
|
|
|
const deliverTarget = `channel:${interactionCtx.channelId}`;
|
|
const typingChannelId = interactionCtx.channelId;
|
|
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
|
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 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: {
|
|
...prefixOptions,
|
|
humanDelay: resolveHumanDelayConfig(ctx.cfg, agentId),
|
|
deliver: async (payload) => {
|
|
const replyToId = replyReference.use();
|
|
await deliverDiscordReply({
|
|
replies: [payload],
|
|
target: deliverTarget,
|
|
token,
|
|
accountId,
|
|
rest: interaction.client.rest,
|
|
runtime,
|
|
replyToId,
|
|
replyToMode,
|
|
textLimit,
|
|
maxLinesPerMessage: ctx.discordConfig?.maxLinesPerMessage,
|
|
tableMode,
|
|
chunkMode: resolveChunkMode(ctx.cfg, "discord", accountId),
|
|
});
|
|
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,
|
|
guildEntries: params.ctx.guildEntries,
|
|
});
|
|
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
|
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: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
|
});
|
|
if (!memberAllowed) {
|
|
return;
|
|
}
|
|
|
|
const componentAllowed = await ensureComponentUserAllowed({
|
|
entry,
|
|
interaction: params.interaction,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: params.componentLabel,
|
|
unauthorizedReply,
|
|
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
|
});
|
|
if (!componentAllowed) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
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,
|
|
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: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
|
});
|
|
if (!memberAllowed) {
|
|
return;
|
|
}
|
|
|
|
const componentAllowed = await ensureComponentUserAllowed({
|
|
entry,
|
|
interaction: params.interaction,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: "form",
|
|
unauthorizedReply,
|
|
allowNameMatching: params.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
|
});
|
|
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 = "*";
|
|
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 = "*";
|
|
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 = "*";
|
|
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 = "*";
|
|
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 = "*";
|
|
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 = "*";
|
|
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 = "*";
|
|
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,
|
|
guildEntries: this.ctx.guildEntries,
|
|
});
|
|
const channelCtx = resolveDiscordChannelContext(interaction);
|
|
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
|
interaction,
|
|
guildInfo,
|
|
channelId,
|
|
rawGuildId,
|
|
channelCtx,
|
|
memberRoleIds,
|
|
user,
|
|
replyOpts,
|
|
componentLabel: "form",
|
|
unauthorizedReply: "You are not authorized to use this form.",
|
|
allowNameMatching: this.ctx.discordConfig?.dangerouslyAllowNameMatching === true,
|
|
});
|
|
if (!memberAllowed) {
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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);
|
|
}
|