mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
Discord: add slash command handling
This commit is contained in:
committed by
Peter Steinberger
parent
17e17f85ae
commit
b135b3efb9
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,8 +25,9 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
|
||||
6. Guild channels: use `channel:<channelId>` 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.<id>.users`: optional per-guild user allowlist (ids or names).
|
||||
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
||||
- `guilds.<id>.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"`
|
||||
|
||||
@@ -2,6 +2,7 @@ export type MsgContext = {
|
||||
Body?: string;
|
||||
From?: string;
|
||||
To?: string;
|
||||
SessionKey?: string;
|
||||
MessageSid?: string;
|
||||
ReplyToId?: string;
|
||||
ReplyToBody?: string;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user