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
|
### Discord
|
||||||
|
|
||||||
- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
|
- 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
|
```json5
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ Configure the Discord bot by setting the bot token and optional gating:
|
|||||||
token: "your-bot-token",
|
token: "your-bot-token",
|
||||||
mediaMaxMb: 8, // clamp inbound media size
|
mediaMaxMb: 8, // clamp inbound media size
|
||||||
enableReactions: true, // allow agent-triggered reactions
|
enableReactions: true, // allow agent-triggered reactions
|
||||||
|
slashCommand: { // user-installed app slash commands
|
||||||
|
enabled: true,
|
||||||
|
name: "clawd",
|
||||||
|
sessionPrefix: "discord:slash",
|
||||||
|
ephemeral: true
|
||||||
|
},
|
||||||
dm: {
|
dm: {
|
||||||
enabled: true, // disable all DMs when false
|
enabled: true, // disable all DMs when false
|
||||||
allowFrom: ["1234567890", "steipete"], // optional DM allowlist (ids or names)
|
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.
|
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.
|
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.
|
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.
|
9. Optional slash commands: enable `discord.slashCommand` to accept user-installed app commands (ephemeral replies). Slash invocations respect the same DM/guild allowlists.
|
||||||
10. Reactions (default on): set `discord.enableReactions = false` to disable agent-triggered reactions via the `clawdis_discord` tool.
|
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: 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 `#`.
|
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",
|
token: "abc.123",
|
||||||
mediaMaxMb: 8,
|
mediaMaxMb: 8,
|
||||||
enableReactions: true,
|
enableReactions: true,
|
||||||
|
slashCommand: {
|
||||||
|
enabled: true,
|
||||||
|
name: "clawd",
|
||||||
|
sessionPrefix: "discord:slash",
|
||||||
|
ephemeral: true
|
||||||
|
},
|
||||||
dm: {
|
dm: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
allowFrom: ["123456789012345678", "steipete"],
|
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>.users`: optional per-guild user allowlist (ids or names).
|
||||||
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
|
||||||
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
|
- `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.
|
- `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).
|
- `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`).
|
- `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
|
## Reactions
|
||||||
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
|
When `discord.enableReactions = true`, the agent can call `clawdis_discord` with:
|
||||||
- `action: "react"`
|
- `action: "react"`
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export type MsgContext = {
|
|||||||
Body?: string;
|
Body?: string;
|
||||||
From?: string;
|
From?: string;
|
||||||
To?: string;
|
To?: string;
|
||||||
|
SessionKey?: string;
|
||||||
MessageSid?: string;
|
MessageSid?: string;
|
||||||
ReplyToId?: string;
|
ReplyToId?: string;
|
||||||
ReplyToBody?: string;
|
ReplyToBody?: string;
|
||||||
|
|||||||
@@ -192,6 +192,17 @@ export type DiscordGuildEntry = {
|
|||||||
channels?: Record<string, DiscordGuildChannelConfig>;
|
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 = {
|
export type DiscordConfig = {
|
||||||
/** If false, do not start the Discord provider. Default: true. */
|
/** If false, do not start the Discord provider. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -200,6 +211,7 @@ export type DiscordConfig = {
|
|||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
/** Allow agent-triggered Discord reactions (default: true). */
|
/** Allow agent-triggered Discord reactions (default: true). */
|
||||||
enableReactions?: boolean;
|
enableReactions?: boolean;
|
||||||
|
slashCommand?: DiscordSlashCommandConfig;
|
||||||
dm?: DiscordDmConfig;
|
dm?: DiscordDmConfig;
|
||||||
/** New per-guild config keyed by guild id or slug. */
|
/** New per-guild config keyed by guild id or slug. */
|
||||||
guilds?: Record<string, DiscordGuildEntry>;
|
guilds?: Record<string, DiscordGuildEntry>;
|
||||||
@@ -936,6 +948,14 @@ const ClawdisSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
token: z.string().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(),
|
mediaMaxMb: z.number().positive().optional(),
|
||||||
historyLimit: z.number().int().min(0).optional(),
|
historyLimit: z.number().int().min(0).optional(),
|
||||||
enableReactions: z.boolean().optional(),
|
enableReactions: z.boolean().optional(),
|
||||||
|
|||||||
@@ -349,6 +349,8 @@ export function resolveSessionKey(
|
|||||||
ctx: MsgContext,
|
ctx: MsgContext,
|
||||||
mainKey?: string,
|
mainKey?: string,
|
||||||
) {
|
) {
|
||||||
|
const explicit = ctx.SessionKey?.trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
const raw = deriveSessionKey(scope, ctx);
|
const raw = deriveSessionKey(scope, ctx);
|
||||||
if (scope === "global") return raw;
|
if (scope === "global") return raw;
|
||||||
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
// Default to a single shared direct-chat session called "main"; groups stay isolated.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
ApplicationCommandOptionType,
|
||||||
ChannelType,
|
ChannelType,
|
||||||
|
type CommandInteractionOption,
|
||||||
Client,
|
Client,
|
||||||
Events,
|
Events,
|
||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
@@ -11,20 +13,23 @@ import { chunkText } from "../auto-reply/chunk.js";
|
|||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.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 { loadConfig } from "../config/config.js";
|
||||||
import { resolveStorePath, updateLastRoute } from "../config/sessions.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 { getChildLogger } from "../logging.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
import { saveMediaBuffer } from "../media/store.js";
|
import { saveMediaBuffer } from "../media/store.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { sendMessageDiscord } from "./send.js";
|
import { sendMessageDiscord } from "./send.js";
|
||||||
import { normalizeDiscordToken } from "./token.js";
|
import { normalizeDiscordToken } from "./token.js";
|
||||||
|
import type { DiscordSlashCommandConfig } from "../config/config.js";
|
||||||
|
|
||||||
export type MonitorDiscordOpts = {
|
export type MonitorDiscordOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
|
slashCommand?: DiscordSlashCommandConfig;
|
||||||
mediaMaxMb?: number;
|
mediaMaxMb?: number;
|
||||||
historyLimit?: number;
|
historyLimit?: number;
|
||||||
};
|
};
|
||||||
@@ -86,6 +91,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
const dmConfig = cfg.discord?.dm;
|
const dmConfig = cfg.discord?.dm;
|
||||||
const guildEntries = cfg.discord?.guilds;
|
const guildEntries = cfg.discord?.guilds;
|
||||||
const allowFrom = dmConfig?.allowFrom;
|
const allowFrom = dmConfig?.allowFrom;
|
||||||
|
const slashCommand = resolveSlashCommandConfig(
|
||||||
|
opts.slashCommand ?? cfg.discord?.slashCommand,
|
||||||
|
);
|
||||||
const mediaMaxBytes =
|
const mediaMaxBytes =
|
||||||
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
(opts.mediaMaxMb ?? cfg.discord?.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||||
const historyLimit = Math.max(
|
const historyLimit = Math.max(
|
||||||
@@ -111,6 +119,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
client.once(Events.ClientReady, () => {
|
client.once(Events.ClientReady, () => {
|
||||||
runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
|
runtime.log?.(`logged in as ${client.user?.tag ?? "unknown"}`);
|
||||||
|
if (slashCommand.enabled) {
|
||||||
|
void ensureSlashCommand(client, slashCommand, runtime);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.Error, (err) => {
|
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 client.login(token);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
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) {
|
async function sendTyping(message: Message) {
|
||||||
try {
|
try {
|
||||||
const channel = message.channel;
|
const channel = message.channel;
|
||||||
@@ -659,3 +905,45 @@ async function deliverReplies({
|
|||||||
runtime.log?.(`delivered reply to ${target}`);
|
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(),
|
token: discordToken.trim(),
|
||||||
runtime: discordRuntimeEnv,
|
runtime: discordRuntimeEnv,
|
||||||
abortSignal: discordAbort.signal,
|
abortSignal: discordAbort.signal,
|
||||||
|
slashCommand: cfg.discord?.slashCommand,
|
||||||
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
mediaMaxMb: cfg.discord?.mediaMaxMb,
|
||||||
historyLimit: cfg.discord?.historyLimit,
|
historyLimit: cfg.discord?.historyLimit,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user