import { Button, ChannelType, Command, Row, type AutocompleteInteraction, type ButtonInteraction, type CommandInteraction, type CommandOptions, type ComponentData, } from "@buape/carbon"; import { ApplicationCommandOptionType, ButtonStyle } from "discord-api-types/v10"; import { resolveEffectiveMessagesConfig, resolveHumanDelayConfig } from "../../agents/identity.js"; import { resolveChunkMode, resolveTextChunkLimit } from "../../auto-reply/chunk.js"; import { buildCommandTextFromArgs, findCommandByNativeName, listChatCommands, parseCommandArgs, resolveCommandArgChoices, resolveCommandArgMenu, serializeCommandArgs, } from "../../auto-reply/commands-registry.js"; import type { ChatCommandDefinition, CommandArgDefinition, CommandArgValues, CommandArgs, NativeCommandSpec, } from "../../auto-reply/commands-registry.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ClawdbotConfig, loadConfig } from "../../config/config.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { readChannelAllowFromStore, upsertChannelPairingRequest, } from "../../pairing/pairing-store.js"; import { resolveAgentRoute } from "../../routing/resolve-route.js"; import { loadWebMedia } from "../../web/media.js"; import { chunkDiscordTextWithMode } from "../chunk.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { allowListMatches, isDiscordGroupAllowedByPolicy, normalizeDiscordAllowList, normalizeDiscordSlug, resolveDiscordChannelConfigWithFallback, resolveDiscordGuildEntry, resolveDiscordUserAllowed, } from "./allow-list.js"; import { formatDiscordUserTag } from "./format.js"; import { resolveDiscordChannelInfo } from "./message-utils.js"; import { resolveDiscordThreadParentInfo } from "./threading.js"; type DiscordConfig = NonNullable["discord"]; function buildDiscordCommandOptions(params: { command: ChatCommandDefinition; cfg: ReturnType; }): CommandOptions | undefined { const { command, cfg } = params; const args = command.args; if (!args || args.length === 0) return undefined; return args.map((arg) => { const required = arg.required ?? false; if (arg.type === "number") { return { name: arg.name, description: arg.description, type: ApplicationCommandOptionType.Number, required, }; } if (arg.type === "boolean") { return { name: arg.name, description: arg.description, type: ApplicationCommandOptionType.Boolean, required, }; } const resolvedChoices = resolveCommandArgChoices({ command, arg, cfg }); const shouldAutocomplete = resolvedChoices.length > 0 && (typeof arg.choices === "function" || resolvedChoices.length > 25); const autocomplete = shouldAutocomplete ? async (interaction: AutocompleteInteraction) => { const focused = interaction.options.getFocused(); const focusValue = typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : ""; const choices = resolveCommandArgChoices({ command, arg, cfg }); const filtered = focusValue ? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue)) : choices; await interaction.respond( filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })), ); } : undefined; const choices = resolvedChoices.length > 0 && !autocomplete ? resolvedChoices .slice(0, 25) .map((choice) => ({ name: choice.label, value: choice.value })) : undefined; return { name: arg.name, description: arg.description, type: ApplicationCommandOptionType.String, required, choices, autocomplete, }; }) satisfies CommandOptions; } function readDiscordCommandArgs( interaction: CommandInteraction, definitions?: CommandArgDefinition[], ): CommandArgs | undefined { if (!definitions || definitions.length === 0) return undefined; const values: CommandArgValues = {}; for (const definition of definitions) { let value: string | number | boolean | null | undefined; if (definition.type === "number") { value = interaction.options.getNumber(definition.name) ?? null; } else if (definition.type === "boolean") { value = interaction.options.getBoolean(definition.name) ?? null; } else { value = interaction.options.getString(definition.name) ?? null; } if (value != null) { values[definition.name] = value; } } return Object.keys(values).length > 0 ? { values } : undefined; } function chunkItems(items: T[], size: number): T[][] { if (size <= 0) return [items]; const rows: T[][] = []; for (let i = 0; i < items.length; i += size) { rows.push(items.slice(i, i + size)); } return rows; } const DISCORD_COMMAND_ARG_CUSTOM_ID_KEY = "cmdarg"; function createCommandArgsWithValue(params: { argName: string; value: string }): CommandArgs { const values: CommandArgValues = { [params.argName]: params.value }; return { values }; } function encodeDiscordCommandArgValue(value: string): string { return encodeURIComponent(value); } function decodeDiscordCommandArgValue(value: string): string { try { return decodeURIComponent(value); } catch { return value; } } function isDiscordUnknownInteraction(error: unknown): boolean { if (!error || typeof error !== "object") return false; const err = error as { discordCode?: number; status?: number; message?: string; rawBody?: { code?: number; message?: string }; }; if (err.discordCode === 10062 || err.rawBody?.code === 10062) return true; if (err.status === 404 && /Unknown interaction/i.test(err.message ?? "")) return true; if (/Unknown interaction/i.test(err.rawBody?.message ?? "")) return true; return false; } async function safeDiscordInteractionCall( label: string, fn: () => Promise, ): Promise { try { return await fn(); } catch (error) { if (isDiscordUnknownInteraction(error)) { console.warn(`discord: ${label} skipped (interaction expired)`); return null; } throw error; } } function buildDiscordCommandArgCustomId(params: { command: string; arg: string; value: string; userId: string; }): string { return [ `${DISCORD_COMMAND_ARG_CUSTOM_ID_KEY}:command=${encodeDiscordCommandArgValue(params.command)}`, `arg=${encodeDiscordCommandArgValue(params.arg)}`, `value=${encodeDiscordCommandArgValue(params.value)}`, `user=${encodeDiscordCommandArgValue(params.userId)}`, ].join(";"); } function parseDiscordCommandArgData( data: ComponentData, ): { command: string; arg: string; value: string; userId: string } | null { if (!data || typeof data !== "object") return null; const coerce = (value: unknown) => typeof value === "string" || typeof value === "number" ? String(value) : ""; const rawCommand = coerce(data.command); const rawArg = coerce(data.arg); const rawValue = coerce(data.value); const rawUser = coerce(data.user); if (!rawCommand || !rawArg || !rawValue || !rawUser) return null; return { command: decodeDiscordCommandArgValue(rawCommand), arg: decodeDiscordCommandArgValue(rawArg), value: decodeDiscordCommandArgValue(rawValue), userId: decodeDiscordCommandArgValue(rawUser), }; } type DiscordCommandArgContext = { cfg: ReturnType; discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; }; async function handleDiscordCommandArgInteraction( interaction: ButtonInteraction, data: ComponentData, ctx: DiscordCommandArgContext, ) { const parsed = parseDiscordCommandArgData(data); if (!parsed) { await safeDiscordInteractionCall("command arg update", () => interaction.update({ content: "Sorry, that selection is no longer available.", components: [], }), ); return; } if (interaction.user?.id && interaction.user.id !== parsed.userId) { await safeDiscordInteractionCall("command arg ack", () => interaction.acknowledge()); return; } const commandDefinition = findCommandByNativeName(parsed.command, "discord") ?? listChatCommands().find((entry) => entry.key === parsed.command); if (!commandDefinition) { await safeDiscordInteractionCall("command arg update", () => interaction.update({ content: "Sorry, that command is no longer available.", components: [], }), ); return; } const updated = await safeDiscordInteractionCall("command arg update", () => interaction.update({ content: `✅ Selected ${parsed.value}.`, components: [], }), ); if (!updated) return; const commandArgs = createCommandArgsWithValue({ argName: parsed.arg, value: parsed.value, }); const commandArgsWithRaw: CommandArgs = { ...commandArgs, raw: serializeCommandArgs(commandDefinition, commandArgs), }; const prompt = buildCommandTextFromArgs(commandDefinition, commandArgsWithRaw); await dispatchDiscordCommandInteraction({ interaction, prompt, command: commandDefinition, commandArgs: commandArgsWithRaw, cfg: ctx.cfg, discordConfig: ctx.discordConfig, accountId: ctx.accountId, sessionPrefix: ctx.sessionPrefix, preferFollowUp: true, }); } class DiscordCommandArgButton extends Button { label: string; customId: string; style = ButtonStyle.Secondary; private cfg: ReturnType; private discordConfig: DiscordConfig; private accountId: string; private sessionPrefix: string; constructor(params: { label: string; customId: string; cfg: ReturnType; discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; }) { super(); this.label = params.label; this.customId = params.customId; this.cfg = params.cfg; this.discordConfig = params.discordConfig; this.accountId = params.accountId; this.sessionPrefix = params.sessionPrefix; } async run(interaction: ButtonInteraction, data: ComponentData) { await handleDiscordCommandArgInteraction(interaction, data, { cfg: this.cfg, discordConfig: this.discordConfig, accountId: this.accountId, sessionPrefix: this.sessionPrefix, }); } } class DiscordCommandArgFallbackButton extends Button { label = "cmdarg"; customId = "cmdarg:seed=1"; private ctx: DiscordCommandArgContext; constructor(ctx: DiscordCommandArgContext) { super(); this.ctx = ctx; } async run(interaction: ButtonInteraction, data: ComponentData) { await handleDiscordCommandArgInteraction(interaction, data, this.ctx); } } export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgContext): Button { return new DiscordCommandArgFallbackButton(params); } function buildDiscordCommandArgMenu(params: { command: ChatCommandDefinition; menu: { arg: CommandArgDefinition; choices: Array<{ value: string; label: string }>; title?: string; }; interaction: CommandInteraction; cfg: ReturnType; discordConfig: DiscordConfig; accountId: string; sessionPrefix: string; }): { content: string; components: Row