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(); 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 { 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); }