import { 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 { ChannelType } from "discord-api-types/v10"; import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime"; import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; import { issuePairingChallenge, upsertChannelPairingRequest, } from "openclaw/plugin-sdk/conversation-runtime"; import { resolveAgentRoute } from "openclaw/plugin-sdk/routing"; import { logVerbose } from "openclaw/plugin-sdk/runtime-env"; import { readStoreAllowFromForDmPolicy, resolvePinnedMainDmOwnerFromAllowlist, } from "openclaw/plugin-sdk/security-runtime"; import { logError } from "openclaw/plugin-sdk/text-runtime"; import { createDiscordFormModal, parseDiscordComponentCustomId, parseDiscordModalCustomId, type DiscordComponentEntry, type DiscordModalEntry, } from "../components.js"; import { type DiscordGuildEntryResolved, normalizeDiscordAllowList, normalizeDiscordSlug, resolveDiscordAllowListMatch, resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, resolveDiscordMemberAccessState, resolveDiscordOwnerAccess, } from "./allow-list.js"; import { formatDiscordUserTag } from "./format.js"; export const AGENT_BUTTON_KEY = "agent"; export const AGENT_SELECT_KEY = "agentsel"; export type DiscordUser = Parameters[0]; export type AgentComponentMessageInteraction = | ButtonInteraction | StringSelectMenuInteraction | RoleSelectMenuInteraction | UserSelectMenuInteraction | MentionableSelectMenuInteraction | ChannelSelectMenuInteraction; export type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction; export type DiscordChannelContext = { channelName: string | undefined; channelSlug: string; channelType: number | undefined; isThread: boolean; parentId: string | undefined; parentName: string | undefined; parentSlug: string; }; export type AgentComponentContext = { cfg: OpenClawConfig; accountId: string; discordConfig?: DiscordAccountConfig; runtime?: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv; token?: string; guildEntries?: Record; allowFrom?: string[]; dmPolicy?: "open" | "pairing" | "allowlist" | "disabled"; }; export type ComponentInteractionContext = NonNullable< Awaited> >; function formatUsername(user: { username: string; discriminator?: string | null }): string { if (user.discriminator && user.discriminator !== "0") { return `${user.username}#${user.discriminator}`; } return user.username; } function isThreadChannelType(channelType: number | undefined): boolean { return ( channelType === ChannelType.PublicThread || channelType === ChannelType.PrivateThread || channelType === ChannelType.AnnouncementThread ); } function readParsedComponentId(data: ComponentData): unknown { if (!data || typeof data !== "object") { return undefined; } return "cid" in data ? (data as Record).cid : (data as Record).componentId; } 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 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); } /** * The component custom id only carries the logical button id. Channel binding * comes from Discord's trusted interaction payload. */ export function buildAgentButtonCustomId(componentId: string): string { return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`; } export function buildAgentSelectCustomId(componentId: string): string { return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`; } export 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, }); } export 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)}`); } } export 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 }; } export async function resolveComponentInteractionContext(params: { interaction: AgentComponentInteraction; label: string; defer?: boolean; }) { const { interaction, label } = params; 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; 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; 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, }; } export async function ensureGuildComponentMemberAllowed(params: { interaction: AgentComponentInteraction; guildInfo: ReturnType; channelId: string; rawGuildId: string | undefined; channelCtx: DiscordChannelContext; memberRoleIds: string[]; user: DiscordUser; replyOpts: { ephemeral?: boolean }; componentLabel: string; unauthorizedReply: string; allowNameMatching: 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 {} return false; } export async function ensureComponentUserAllowed(params: { entry: DiscordComponentEntry; interaction: AgentComponentInteraction; user: DiscordUser; replyOpts: { ephemeral?: boolean }; componentLabel: string; unauthorizedReply: string; allowNameMatching: 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 {} return false; } export async function ensureAgentComponentInteractionAllowed(params: { ctx: AgentComponentContext; interaction: AgentComponentInteraction; channelId: string; rawGuildId: string | undefined; memberRoleIds: string[]; user: DiscordUser; replyOpts: { ephemeral?: boolean }; componentLabel: string; unauthorizedReply: string; }) { const guildInfo = resolveDiscordGuildEntry({ guild: params.interaction.guild ?? undefined, guildId: params.rawGuildId, 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: isDangerousNameMatchingEnabled(params.ctx.discordConfig), }); if (!memberAllowed) { return null; } return { parentId: channelCtx.parentId }; } export function parseAgentComponentData(data: ComponentData): { componentId: string } | null { const raw = readParsedComponentId(data); const decodeSafe = (value: string): string => { if (!value.includes("%")) { return value; } if (!/%[0-9A-Fa-f]{2}/.test(value)) { return value; } try { return decodeURIComponent(value); } catch { return value; } }; const componentId = typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null; if (!componentId) { return null; } return { componentId }; } async function ensureDmComponentAuthorized(params: { ctx: AgentComponentContext; interaction: AgentComponentInteraction; user: DiscordUser; componentLabel: string; replyOpts: { ephemeral?: 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 {} return false; } if (dmPolicy === "open") { return true; } const storeAllowFrom = await readStoreAllowFromForDmPolicy({ provider: "discord", accountId: ctx.accountId, dmPolicy, }); 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: isDangerousNameMatchingEnabled(ctx.discordConfig), }) : { allowed: false }; if (allowMatch.allowed) { return true; } if (dmPolicy === "pairing") { const pairingResult = await issuePairingChallenge({ channel: "discord", senderId: user.id, senderIdLine: `Your Discord user id: ${user.id}`, meta: { tag: formatDiscordUserTag(user), name: user.username, }, upsertPairingRequest: async ({ id, meta }) => await upsertChannelPairingRequest({ channel: "discord", id, accountId: ctx.accountId, meta, }), sendPairingReply: async (text) => { await interaction.reply({ content: text, ...replyOpts, }); }, }); if (!pairingResult.created) { try { await interaction.reply({ content: "Pairing already requested. Ask the bot owner to approve your code.", ...replyOpts, }); } catch {} } 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 {} return false; } export async function resolveInteractionContextWithDmAuth(params: { ctx: AgentComponentContext; interaction: AgentComponentInteraction; label: string; componentLabel: string; defer?: boolean; }) { 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; } export function parseDiscordComponentData( data: ComponentData, customId?: string, ): { componentId: string; modalId?: string } | null { if (!data || typeof data !== "object") { return null; } const rawComponentId = readParsedComponentId(data); 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 }; } export 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; } export 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; } export 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; } export 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 []; } } export 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"); } export function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string { const rawId = interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData ? (interaction.rawData as { id?: unknown }).id : undefined; if (typeof rawId === "string" && rawId.trim()) { return rawId.trim(); } if (typeof rawId === "number" && Number.isFinite(rawId)) { return String(rawId); } return `discord-interaction:${Date.now()}`; } export function resolveComponentCommandAuthorized(params: { ctx: AgentComponentContext; interactionCtx: ComponentInteractionContext; channelConfig: ReturnType; guildInfo: ReturnType; allowNameMatching: boolean; }) { const { ctx, interactionCtx, channelConfig, guildInfo } = params; if (interactionCtx.isDirectMessage) { return true; } const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({ allowFrom: ctx.allowFrom, sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: formatDiscordUserTag(interactionCtx.user), }, allowNameMatching: params.allowNameMatching, }); const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({ channelConfig, guildInfo, memberRoleIds: interactionCtx.memberRoleIds, sender: { id: interactionCtx.user.id, name: interactionCtx.user.username, tag: formatDiscordUserTag(interactionCtx.user), }, allowNameMatching: params.allowNameMatching, }); const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false; const authorizers = useAccessGroups ? [ { configured: ownerAllowList != null, allowed: ownerOk }, { configured: hasAccessRestrictions, allowed: memberAllowed }, ] : [{ configured: hasAccessRestrictions, allowed: memberAllowed }]; return resolveCommandAuthorizedFromAuthorizers({ useAccessGroups, authorizers, modeWhenAccessGroupsOff: "configured", }); } export { resolveDiscordGuildEntry, resolvePinnedMainDmOwnerFromAllowlist };