- Introduced `applyCommandCooldown` function to manage per-command cooldowns after execution, improving command rate limiting. - Updated command execution flow to include cooldown application, ensuring users are informed of cooldown status. - Enhanced error handling in interaction replies, allowing for more graceful error management and user feedback. - Improved the handling of interaction responses across various commands, ensuring consistent user experience. - Added permission checks for new commands, ensuring only authorized users can execute specific actions.
297 lines
9.6 KiB
TypeScript
297 lines
9.6 KiB
TypeScript
import {
|
|
REST,
|
|
Routes,
|
|
type ChatInputCommandInteraction,
|
|
type MessageContextMenuCommandInteraction
|
|
} from 'discord.js';
|
|
import { t, tf, type Locale } from '@nexumi/shared';
|
|
import { env } from './env.js';
|
|
import { logger } from './logger.js';
|
|
import { getGuildLocale } from './i18n.js';
|
|
import { evaluateCommandGate, applyCommandCooldown, type CommandGateReason } from './command-gates.js';
|
|
import {
|
|
isGuildBlacklisted,
|
|
isModuleEnabled,
|
|
isUserBlacklisted,
|
|
moduleForCommand
|
|
} from './module-gates.js';
|
|
import { isMaintenanceMode } from './presence.js';
|
|
import { recordCommandMetric } from './metrics.js';
|
|
import { captureException } from './sentry.js';
|
|
import { moderationCommands } from './modules/moderation/commands.js';
|
|
import { automodCommands } from './modules/automod/commands.js';
|
|
import { welcomeCommands } from './modules/welcome/commands.js';
|
|
import { verificationCommands } from './modules/verification/commands.js';
|
|
import { levelingCommands } from './modules/leveling/commands.js';
|
|
import { economyCommands } from './modules/economy/commands.js';
|
|
import { utilityCommands, utilityContextMenus } from './modules/utility/index.js';
|
|
import { funCommands } from './modules/fun/index.js';
|
|
import { giveawayCommands } from './modules/giveaways/index.js';
|
|
import { ticketsCommands } from './modules/tickets/index.js';
|
|
import { selfrolesCommands } from './modules/selfroles/index.js';
|
|
import { tagsCommands } from './modules/tags/index.js';
|
|
import { starboardCommands } from './modules/starboard/index.js';
|
|
import { suggestionsCommands } from './modules/suggestions/index.js';
|
|
import { birthdayCommands } from './modules/birthdays/index.js';
|
|
import { tempVoiceCommands } from './modules/tempvoice/index.js';
|
|
import { statsCommands } from './modules/stats/index.js';
|
|
import { feedsCommands } from './modules/feeds/index.js';
|
|
import { scheduleCommands } from './modules/scheduler/index.js';
|
|
import { guildBackupCommands } from './modules/guildbackup/index.js';
|
|
import { coreCommands } from './modules/core/index.js';
|
|
import { gdprCommands } from './modules/gdpr/index.js';
|
|
import type { BotContext, SlashCommand } from './types.js';
|
|
|
|
const commands: SlashCommand[] = [
|
|
...coreCommands,
|
|
...gdprCommands,
|
|
...moderationCommands,
|
|
...automodCommands,
|
|
...welcomeCommands,
|
|
...verificationCommands,
|
|
...levelingCommands,
|
|
...economyCommands,
|
|
...utilityCommands,
|
|
...funCommands,
|
|
...giveawayCommands,
|
|
...ticketsCommands,
|
|
...selfrolesCommands,
|
|
...tagsCommands,
|
|
...starboardCommands,
|
|
...suggestionsCommands,
|
|
...birthdayCommands,
|
|
...tempVoiceCommands,
|
|
...statsCommands,
|
|
...feedsCommands,
|
|
...scheduleCommands,
|
|
...guildBackupCommands
|
|
];
|
|
const map = new Map(commands.map((c) => [c.data.name, c]));
|
|
const contextMenuMap = new Map(utilityContextMenus.map((c) => [c.data.name, c]));
|
|
|
|
function buildCommandBody(): object[] {
|
|
const seen = new Set<string>();
|
|
const body: object[] = [];
|
|
for (const command of [...commands, ...utilityContextMenus]) {
|
|
const json = command.data.toJSON() as { name: string; type?: number };
|
|
const key = `${json.type ?? 1}:${json.name}`;
|
|
if (seen.has(key)) {
|
|
logger.warn({ name: json.name, type: json.type }, 'Skipping duplicate command definition');
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
body.push(json);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
/**
|
|
* Guild-scoped leftovers + global commands both appear in Discord's picker.
|
|
* Always clear the unused scope so each command shows once.
|
|
*/
|
|
async function clearGuildCommandLeftovers(rest: REST, clientId: string): Promise<number> {
|
|
const guilds = (await rest.get(Routes.userGuilds())) as Array<{ id: string }>;
|
|
let cleared = 0;
|
|
for (const guild of guilds) {
|
|
const existing = (await rest.get(
|
|
Routes.applicationGuildCommands(clientId, guild.id)
|
|
)) as unknown[];
|
|
if (existing.length === 0) {
|
|
continue;
|
|
}
|
|
await rest.put(Routes.applicationGuildCommands(clientId, guild.id), { body: [] });
|
|
cleared += 1;
|
|
}
|
|
return cleared;
|
|
}
|
|
|
|
export async function registerCommands() {
|
|
const rest = new REST({ version: '10' }).setToken(env.BOT_TOKEN);
|
|
const body = buildCommandBody();
|
|
|
|
// Guild commands sync instantly; global can take up to ~1h.
|
|
if (env.BOT_GUILD_ID) {
|
|
await rest.put(Routes.applicationGuildCommands(env.BOT_CLIENT_ID, env.BOT_GUILD_ID), {
|
|
body
|
|
});
|
|
// Remove global copies so the guild does not show every command twice.
|
|
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body: [] });
|
|
logger.info(
|
|
{
|
|
scope: 'guild',
|
|
guildId: env.BOT_GUILD_ID,
|
|
count: body.length,
|
|
clearedGlobal: true
|
|
},
|
|
'Registered guild application commands and cleared global commands'
|
|
);
|
|
return;
|
|
}
|
|
|
|
await rest.put(Routes.applicationCommands(env.BOT_CLIENT_ID), { body });
|
|
|
|
// Wipe leftover guild-scoped registrations from earlier BOT_GUILD_ID / shard runs.
|
|
let clearedGuilds = 0;
|
|
try {
|
|
clearedGuilds = await clearGuildCommandLeftovers(rest, env.BOT_CLIENT_ID);
|
|
} catch (error) {
|
|
logger.warn({ error }, 'Failed to clear leftover guild application commands');
|
|
}
|
|
|
|
logger.info(
|
|
{
|
|
scope: 'global',
|
|
count: body.length,
|
|
clearedGuilds
|
|
},
|
|
'Registered global application commands and cleared guild-scoped leftovers'
|
|
);
|
|
}
|
|
|
|
function gateMessage(locale: Locale, reason: CommandGateReason, retryAfterSeconds?: number): string {
|
|
switch (reason) {
|
|
case 'disabled':
|
|
return t(locale, 'generic.commandDisabled');
|
|
case 'channel_denied':
|
|
case 'channel_not_allowed':
|
|
return t(locale, 'generic.commandChannelBlocked');
|
|
case 'role_denied':
|
|
case 'role_not_allowed':
|
|
return t(locale, 'generic.commandRoleBlocked');
|
|
case 'cooldown':
|
|
return tf(locale, 'generic.commandCooldown', {
|
|
seconds: retryAfterSeconds ?? 1
|
|
});
|
|
default:
|
|
return t(locale, 'generic.noPermission');
|
|
}
|
|
}
|
|
|
|
export async function routeCommand(interaction: ChatInputCommandInteraction, context: BotContext) {
|
|
const started = Date.now();
|
|
let ok = true;
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
|
|
try {
|
|
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
|
|
await interaction.reply({
|
|
content: t(locale, 'generic.userBlacklisted'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
|
|
await interaction.reply({
|
|
content: t(locale, 'generic.guildBlacklisted'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
const maintenance = await isMaintenanceMode(context);
|
|
const ownerIds = env.OWNER_USER_IDS ?? [];
|
|
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
|
await interaction.reply({
|
|
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
const command = map.get(interaction.commandName);
|
|
if (!command) {
|
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
|
return;
|
|
}
|
|
|
|
const moduleId = moduleForCommand(interaction.commandName);
|
|
if (moduleId && interaction.guildId) {
|
|
const enabled = await isModuleEnabled(
|
|
context.prisma,
|
|
context.redis,
|
|
interaction.guildId,
|
|
moduleId
|
|
);
|
|
if (!enabled) {
|
|
// Allow first-time setup while the module is still disabled (chicken-egg).
|
|
const sub = interaction.options.getSubcommand(false);
|
|
const isBootstrapSetup =
|
|
interaction.commandName === 'starboard' && sub === 'setup';
|
|
if (!isBootstrapSetup) {
|
|
await interaction.reply({
|
|
content: t(locale, 'generic.moduleDisabled'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const gate = await evaluateCommandGate(interaction, context);
|
|
if (!gate.ok) {
|
|
await interaction.reply({
|
|
content: gateMessage(locale, gate.reason, gate.retryAfterSeconds),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
await command.execute(interaction, context);
|
|
await applyCommandCooldown(interaction, context).catch(() => undefined);
|
|
} catch (error) {
|
|
ok = false;
|
|
captureException(error, {
|
|
command: interaction.commandName,
|
|
guildId: interaction.guildId,
|
|
userId: interaction.user.id
|
|
});
|
|
throw error;
|
|
} finally {
|
|
await recordCommandMetric(context.redis, {
|
|
ok,
|
|
durationMs: Date.now() - started
|
|
}).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
export async function routeContextMenu(
|
|
interaction: MessageContextMenuCommandInteraction,
|
|
context: BotContext
|
|
) {
|
|
const locale = await getGuildLocale(context.prisma, interaction.guildId);
|
|
|
|
if (await isUserBlacklisted(context.prisma, interaction.user.id)) {
|
|
await interaction.reply({
|
|
content: t(locale, 'generic.userBlacklisted'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (interaction.guildId && (await isGuildBlacklisted(context.prisma, interaction.guildId))) {
|
|
await interaction.reply({
|
|
content: t(locale, 'generic.guildBlacklisted'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
const maintenance = await isMaintenanceMode(context);
|
|
const ownerIds = env.OWNER_USER_IDS ?? [];
|
|
if (maintenance.enabled && !ownerIds.includes(interaction.user.id)) {
|
|
await interaction.reply({
|
|
content: maintenance.message?.trim() || t(locale, 'generic.maintenance'),
|
|
ephemeral: true
|
|
});
|
|
return;
|
|
}
|
|
|
|
const command = contextMenuMap.get(interaction.commandName);
|
|
if (!command) {
|
|
await interaction.reply({ content: t(locale, 'generic.unknownCommand'), ephemeral: true });
|
|
return;
|
|
}
|
|
await command.execute(interaction, context);
|
|
}
|