Discord: add slash command handling

This commit is contained in:
Shadow
2026-01-02 00:11:03 -06:00
committed by Peter Steinberger
parent 17e17f85ae
commit b135b3efb9
8 changed files with 336 additions and 4 deletions

View File

@@ -2,6 +2,7 @@ export type MsgContext = {
Body?: string;
From?: string;
To?: string;
SessionKey?: string;
MessageSid?: string;
ReplyToId?: string;
ReplyToBody?: string;

View File

@@ -192,6 +192,17 @@ export type DiscordGuildEntry = {
channels?: Record<string, DiscordGuildChannelConfig>;
};
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<string, DiscordGuildEntry>;
@@ -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(),

View File

@@ -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.

View File

@@ -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<void>((resolve, reject) => {
@@ -614,6 +778,88 @@ export function resolveGroupDmAllow(params: {
});
}
async function ensureSlashCommand(
client: Client,
slashCommand: Required<DiscordSlashCommandConfig>,
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<DiscordSlashCommandConfig> {
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 });
}
}

View File

@@ -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,
})