From b135b3efb9184a562d9a0b35331f8b521c8f5284 Mon Sep 17 00:00:00 2001 From: Shadow Date: Fri, 2 Jan 2026 00:11:03 -0600 Subject: [PATCH] Discord: add slash command handling --- README.md | 2 +- docs/configuration.md | 6 + docs/discord.md | 18 ++- src/auto-reply/templating.ts | 1 + src/config/config.ts | 20 +++ src/config/sessions.ts | 2 + src/discord/monitor.ts | 290 ++++++++++++++++++++++++++++++++++- src/gateway/server.ts | 1 + 8 files changed, 336 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 102bb644c..78c372992 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Minimal `~/.clawdis/clawdis.json`: ### Discord - Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins). -- Optional: set `discord.requireMention`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed. +- Optional: set `discord.requireMention`, `discord.slashCommand`, `discord.allowFrom`, or `discord.mediaMaxMb` as needed. ```json5 { diff --git a/docs/configuration.md b/docs/configuration.md index 38bee9cbf..5a90a0a53 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -175,6 +175,12 @@ Configure the Discord bot by setting the bot token and optional gating: token: "your-bot-token", mediaMaxMb: 8, // clamp inbound media size enableReactions: true, // allow agent-triggered reactions + slashCommand: { // user-installed app slash commands + enabled: true, + name: "clawd", + sessionPrefix: "discord:slash", + ephemeral: true + }, dm: { enabled: true, // disable all DMs when false allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names) diff --git a/docs/discord.md b/docs/discord.md index f482588e3..ad6bf7126 100644 --- a/docs/discord.md +++ b/docs/discord.md @@ -25,8 +25,9 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa 6. Guild channels: use `channel:` for delivery. Mentions are required by default and can be set per guild or per channel. 7. Optional DM control: set `discord.dm.enabled = false` to ignore all DMs, or `discord.dm.allowFrom` to allow specific users (ids or names). Use `discord.dm.groupEnabled` + `discord.dm.groupChannels` to allow group DMs. 8. Optional guild rules: set `discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules. -9. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. -10. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool. +9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists. +10. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. +11. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool. Note: Discord does not provide a simple username → id lookup without extra guild context, so prefer ids or `<@id>` mentions for DM delivery targets. Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`. @@ -47,6 +48,12 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea token: "abc.123", mediaMaxMb: 8, enableReactions: true, + slashCommand: { + enabled: true, + name: "clawd", + sessionPrefix: "discord:slash", + ephemeral: true + }, dm: { enabled: true, allowFrom: ["123456789012345678", "steipete"], @@ -77,10 +84,17 @@ Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-rea - `guilds..users`: optional per-guild user allowlist (ids or names). - `guilds..channels`: channel rules (keys are channel slugs or ids). - `guilds..requireMention`: per-guild mention requirement (overridable per channel). +- `slashCommand`: optional config for user-installed slash commands (ephemeral responses). - `mediaMaxMb`: clamp inbound media saved to disk. - `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20, `0` disables). - `enableReactions`: allow agent-triggered reactions via the `clawdis_discord` tool (default `true`). +Slash command notes: +- Register a chat input command in Discord with at least one string option (e.g., `prompt`). +- The first non-empty string option is treated as the prompt. +- Slash commands honor the same allowlists as DMs/guild messages (`discord.dm.allowFrom`, `discord.guilds`, per-channel rules). +- Clawdis will auto-register `/clawd` (or the configured name) if it doesn't already exist. + ## Reactions When `discord.enableReactions = true`, the agent can call `clawdis_discord` with: - `action: "react"` diff --git a/src/auto-reply/templating.ts b/src/auto-reply/templating.ts index 3e3c0ac59..2215a127b 100644 --- a/src/auto-reply/templating.ts +++ b/src/auto-reply/templating.ts @@ -2,6 +2,7 @@ export type MsgContext = { Body?: string; From?: string; To?: string; + SessionKey?: string; MessageSid?: string; ReplyToId?: string; ReplyToBody?: string; diff --git a/src/config/config.ts b/src/config/config.ts index 2ea0300bf..3525ebcf7 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -192,6 +192,17 @@ export type DiscordGuildEntry = { channels?: Record; }; +export type DiscordSlashCommandConfig = { + /** Enable handling for the configured slash command (default: false). */ + enabled?: boolean; + /** Slash command name (default: "clawd"). */ + name?: string; + /** Session key prefix for slash commands (default: "discord:slash"). */ + sessionPrefix?: string; + /** Reply ephemerally (default: true). */ + ephemeral?: boolean; +}; + export type DiscordConfig = { /** If false, do not start the Discord provider. Default: true. */ enabled?: boolean; @@ -200,6 +211,7 @@ export type DiscordConfig = { historyLimit?: number; /** Allow agent-triggered Discord reactions (default: true). */ enableReactions?: boolean; + slashCommand?: DiscordSlashCommandConfig; dm?: DiscordDmConfig; /** New per-guild config keyed by guild id or slug. */ guilds?: Record; @@ -936,6 +948,14 @@ const ClawdisSchema = z.object({ .object({ enabled: z.boolean().optional(), token: z.string().optional(), + slashCommand: z + .object({ + enabled: z.boolean().optional(), + name: z.string().optional(), + sessionPrefix: z.string().optional(), + ephemeral: z.boolean().optional(), + }) + .optional(), mediaMaxMb: z.number().positive().optional(), historyLimit: z.number().int().min(0).optional(), enableReactions: z.boolean().optional(), diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 4768fd368..7457bb0d2 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -349,6 +349,8 @@ export function resolveSessionKey( ctx: MsgContext, mainKey?: string, ) { + const explicit = ctx.SessionKey?.trim(); + if (explicit) return explicit; const raw = deriveSessionKey(scope, ctx); if (scope === "global") return raw; // Default to a single shared direct-chat session called "main"; groups stay isolated. diff --git a/src/discord/monitor.ts b/src/discord/monitor.ts index 78c5708ea..5a542c7b2 100644 --- a/src/discord/monitor.ts +++ b/src/discord/monitor.ts @@ -1,5 +1,7 @@ import { + ApplicationCommandOptionType, ChannelType, + type CommandInteractionOption, Client, Events, GatewayIntentBits, @@ -11,20 +13,23 @@ import { chunkText } from "../auto-reply/chunk.js"; import { formatAgentEnvelope } from "../auto-reply/envelope.js"; import { getReplyFromConfig } from "../auto-reply/reply.js"; import type { ReplyPayload } from "../auto-reply/types.js"; +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; import { loadConfig } from "../config/config.js"; import { resolveStorePath, updateLastRoute } from "../config/sessions.js"; -import { danger, isVerbose, logVerbose } from "../globals.js"; +import { danger, isVerbose, logVerbose, warn } from "../globals.js"; import { getChildLogger } from "../logging.js"; import { detectMime } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; import type { RuntimeEnv } from "../runtime.js"; import { sendMessageDiscord } from "./send.js"; import { normalizeDiscordToken } from "./token.js"; +import type { DiscordSlashCommandConfig } from "../config/config.js"; export type MonitorDiscordOpts = { token?: string; runtime?: RuntimeEnv; abortSignal?: AbortSignal; + slashCommand?: DiscordSlashCommandConfig; mediaMaxMb?: number; historyLimit?: number; }; @@ -86,6 +91,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { const dmConfig = cfg.discord?.dm; const guildEntries = cfg.discord?.guilds; const allowFrom = dmConfig?.allowFrom; + const slashCommand = resolveSlashCommandConfig( + opts.slashCommand ?? cfg.discord?.slashCommand, + ); const mediaMaxBytes = (opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024; const historyLimit = Math.max( @@ -111,6 +119,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { client.once(Events.ClientReady, () => { runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`); + if (slashCommand.enabled) { + void ensureSlashCommand(client, slashCommand, runtime); + } }); client.on(Events.Error, (err) => { @@ -376,6 +387,159 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { } }); + client.on(Events.InteractionCreate, async (interaction) => { + try { + if (!slashCommand.enabled) return; + if (!interaction.isChatInputCommand()) return; + if (interaction.commandName !== slashCommand.name) return; + if (interaction.user?.bot) return; + + const channelType = interaction.channel?.type as ChannelType | undefined; + const isGroupDm = channelType === ChannelType.GroupDM; + const isDirectMessage = + !interaction.inGuild() && channelType === ChannelType.DM; + const isGuildMessage = interaction.inGuild(); + + if (isGroupDm && !groupDmEnabled) return; + if (isDirectMessage && !dmEnabled) return; + + if (isGuildMessage) { + const guildInfo = resolveDiscordGuildEntry({ + guild: interaction.guild ?? null, + guildEntries, + }); + if ( + guildEntries && + Object.keys(guildEntries).length > 0 && + !guildInfo + ) { + logVerbose( + `Blocked discord guild ${interaction.guildId ?? "unknown"} (not in discord.guilds)`, + ); + return; + } + const channelName = + interaction.channel && "name" in interaction.channel + ? interaction.channel.name + : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: interaction.channelId, + channelName, + channelSlug, + }); + if (channelConfig?.allowed === false) { + logVerbose( + `Blocked discord channel ${interaction.channelId} not in guild channel allowlist`, + ); + return; + } + const userAllow = guildInfo?.users; + if (Array.isArray(userAllow) && userAllow.length > 0) { + const users = normalizeDiscordAllowList(userAllow, [ + "discord:", + "user:", + ]); + const userOk = + !users || + allowListMatches(users, { + id: interaction.user.id, + name: interaction.user.username, + tag: interaction.user.tag, + }); + if (!userOk) { + logVerbose( + `Blocked discord guild sender ${interaction.user.id} (not in guild users allowlist)`, + ); + return; + } + } + } else if (isGroupDm) { + const channelName = + interaction.channel && "name" in interaction.channel + ? interaction.channel.name + : undefined; + const channelSlug = channelName ? normalizeDiscordSlug(channelName) : ""; + const groupDmAllowed = resolveGroupDmAllow({ + channels: groupDmChannels, + channelId: interaction.channelId, + channelName, + channelSlug, + }); + if (!groupDmAllowed) return; + } else if (isDirectMessage) { + if (Array.isArray(allowFrom) && allowFrom.length > 0) { + const allowList = normalizeDiscordAllowList(allowFrom, [ + "discord:", + "user:", + ]); + const permitted = + allowList && + allowListMatches(allowList, { + id: interaction.user.id, + name: interaction.user.username, + tag: interaction.user.tag, + }); + if (!permitted) { + logVerbose( + `Blocked unauthorized discord sender ${interaction.user.id} (not in allowFrom)`, + ); + return; + } + } + } + + const prompt = resolveSlashPrompt(interaction.options.data); + if (!prompt) { + await interaction.reply({ + content: "Message required.", + ephemeral: true, + }); + return; + } + + await interaction.deferReply({ ephemeral: slashCommand.ephemeral }); + + const userId = interaction.user.id; + const ctxPayload = { + Body: prompt, + From: `discord:${userId}`, + To: `slash:${userId}`, + ChatType: "direct", + SenderName: interaction.user.username, + Surface: "discord" as const, + WasMentioned: true, + MessageSid: interaction.id, + Timestamp: interaction.createdTimestamp, + SessionKey: `${slashCommand.sessionPrefix}:${userId}`, + }; + + const replyResult = await getReplyFromConfig(ctxPayload, undefined, cfg); + const replies = replyResult + ? Array.isArray(replyResult) + ? replyResult + : [replyResult] + : []; + + await deliverSlashReplies({ + replies, + interaction, + ephemeral: slashCommand.ephemeral, + }); + } catch (err) { + runtime.error?.(danger(`slash handler failed: ${String(err)}`)); + if (interaction.isRepliable()) { + const content = "Sorry, something went wrong handling that command."; + if (interaction.deferred || interaction.replied) { + await interaction.followUp({ content, ephemeral: true }); + } else { + await interaction.reply({ content, ephemeral: true }); + } + } + } + }); + await client.login(token); await new Promise((resolve, reject) => { @@ -614,6 +778,88 @@ export function resolveGroupDmAllow(params: { }); } +async function ensureSlashCommand( + client: Client, + slashCommand: Required, + runtime: RuntimeEnv, +) { + try { + const appCommands = client.application?.commands; + if (!appCommands) { + runtime.error?.(danger("discord slash commands unavailable")); + return; + } + const existing = await appCommands.fetch(); + const hasCommand = Array.from(existing.values()).some( + (entry) => entry.name === slashCommand.name, + ); + if (hasCommand) return; + await appCommands.create({ + name: slashCommand.name, + description: "Ask Clawdis a question", + options: [ + { + name: "prompt", + description: "What should Clawdis help with?", + type: ApplicationCommandOptionType.String, + required: true, + }, + ], + }); + runtime.log?.(`registered discord slash command /${slashCommand.name}`); + } catch (err) { + const status = (err as { status?: number | string })?.status; + const code = (err as { code?: number | string })?.code; + const message = String(err); + const isRateLimit = + status === 429 || + code === 429 || + /rate ?limit/i.test(message); + const text = `discord slash command setup failed: ${message}`; + if (isRateLimit) { + logVerbose(text); + runtime.error?.(warn(text)); + } else { + runtime.error?.(danger(text)); + } + } +} + +function resolveSlashCommandConfig( + raw: DiscordSlashCommandConfig | undefined, +): Required { + return { + enabled: raw ? raw.enabled !== false : false, + name: raw?.name?.trim() || "clawd", + sessionPrefix: raw?.sessionPrefix?.trim() || "discord:slash", + ephemeral: raw?.ephemeral !== false, + }; +} + +function resolveSlashPrompt( + options: readonly CommandInteractionOption[], +): string | undefined { + const direct = findFirstStringOption(options); + if (direct) return direct; + return undefined; +} + +function findFirstStringOption( + options: readonly CommandInteractionOption[], +): string | undefined { + for (const option of options) { + if (typeof option.value === "string") { + const trimmed = option.value.trim(); + if (trimmed) return trimmed; + } + if (option.options && option.options.length > 0) { + const nested = findFirstStringOption(option.options); + if (nested) return nested; + } + } + return undefined; +} + async function sendTyping(message: Message) { try { const channel = message.channel; @@ -659,3 +905,45 @@ async function deliverReplies({ runtime.log?.(`delivered reply to ${target}`); } } + +async function deliverSlashReplies({ + replies, + interaction, + ephemeral, +}: { + replies: ReplyPayload[]; + interaction: import("discord.js").ChatInputCommandInteraction; + ephemeral: boolean; +}) { + const messages: string[] = []; + for (const payload of replies) { + const textRaw = payload.text?.trim() ?? ""; + const text = + textRaw && textRaw !== SILENT_REPLY_TOKEN ? textRaw : undefined; + const mediaList = + payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); + const combined = [ + text ?? "", + ...mediaList.map((url) => url.trim()).filter(Boolean), + ] + .filter(Boolean) + .join("\n"); + if (!combined) continue; + for (const chunk of chunkText(combined, 2000)) { + messages.push(chunk); + } + } + + if (messages.length === 0) { + await interaction.editReply({ + content: "No response was generated for that command.", + }); + return; + } + + const [first, ...rest] = messages; + await interaction.editReply({ content: first }); + for (const message of rest) { + await interaction.followUp({ content: message, ephemeral }); + } +} diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 1ffb6724c..d29974391 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -2254,6 +2254,7 @@ export async function startGatewayServer( token: discordToken.trim(), runtime: discordRuntimeEnv, abortSignal: discordAbort.signal, + slashCommand: cfg.discord?.slashCommand, mediaMaxMb: cfg.discord?.mediaMaxMb, historyLimit: cfg.discord?.historyLimit, })