diff --git a/AGENTS.md b/AGENTS.md index 2ed5e030..4c05466b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -387,7 +387,8 @@ rather than a wait. Do not write the old "up to an hour to propagate" line back - Slash → `events/command/interactionCreate.event.ts` → `checks.ts` gates → `command.run(input, client)` - Prefix → `events/message/…` → `core/prefix.ts` resolves name or alias → **the same** `command.run`. The prefix matches in - any case, so `T?help` — what a phone's auto-capitalisation types — runs `t?help` + any case, so `T?help` — what a phone's auto-capitalisation types — runs `t?help`. A prefix run naming no + subcommand the command has is refused by `runChecks` in the reader's language, before a cooldown starts - Components → the one `interactionCreate` listener → `client.buttons` registry keyed by the custom-ID prefix → `button.run(interaction, { client, action, args })` - Right-click → the same listener → `client.contextMenus` by name → `checkContextMenu` → `menu.run(interaction, client)` diff --git a/src/config/replies.ts b/src/config/replies.ts index 021e4018..fd28a9c1 100644 --- a/src/config/replies.ts +++ b/src/config/replies.ts @@ -8,6 +8,7 @@ const en = { youNeed: "You need these permissions: {permissions}", iNeed: "I need these permissions: {permissions}", iNeedIn: "I need these permissions in {channel}: {permissions}", + pickSubcommand: "That command needs one of these after its name: {subcommands}.", paused: "{bot} is paused right now. The bot owner can resume it from the dashboard.", offHere: "That command is switched off in this server. Anybody with Manage Server can turn it back on.", featureOff: "{bot}'s owner has switched this part of the bot off, so it does not work in any server.", @@ -48,6 +49,7 @@ export const REPLIES: Record> = { youNeed: "Dir fehlen diese Berechtigungen: {permissions}", iNeed: "Mir fehlen diese Berechtigungen: {permissions}", iNeedIn: "In {channel} fehlen mir diese Berechtigungen: {permissions}", + pickSubcommand: "Dieser Befehl braucht nach seinem Namen eines davon: {subcommands}.", paused: "{bot} ist gerade pausiert. Der Bot-Betreiber kann ihn im Dashboard fortsetzen.", offHere: "Dieser Befehl ist auf diesem Server ausgeschaltet. Jeder mit „Server verwalten“ kann ihn wieder einschalten.", @@ -85,6 +87,7 @@ export const REPLIES: Record> = { youNeed: "Necesitas estos permisos: {permissions}", iNeed: "Necesito estos permisos: {permissions}", iNeedIn: "Necesito estos permisos en {channel}: {permissions}", + pickSubcommand: "Ese comando necesita una de estas opciones después de su nombre: {subcommands}.", paused: "{bot} está en pausa ahora mismo. El dueño del bot puede reanudarlo desde el panel.", offHere: "Ese comando está desactivado en este servidor. Cualquiera con Gestionar servidor puede volver a activarlo.", @@ -122,6 +125,7 @@ export const REPLIES: Record> = { youNeed: "Il vous faut ces permissions : {permissions}", iNeed: "Il me faut ces permissions : {permissions}", iNeedIn: "Il me faut ces permissions dans {channel} : {permissions}", + pickSubcommand: "Cette commande attend l’une de ces options après son nom : {subcommands}.", paused: "{bot} est en pause pour le moment. Le propriétaire du bot peut le relancer depuis le tableau de bord.", offHere: "Cette commande est désactivée sur ce serveur. Toute personne ayant Gérer le serveur peut la réactiver.", featureOff: "Le propriétaire de {bot} a désactivé cette partie du bot, elle ne fonctionne donc sur aucun serveur.", @@ -158,6 +162,7 @@ export const REPLIES: Record> = { youNeed: "Ti servono questi permessi: {permissions}", iNeed: "Mi servono questi permessi: {permissions}", iNeedIn: "Mi servono questi permessi in {channel}: {permissions}", + pickSubcommand: "Quel comando richiede una di queste opzioni dopo il nome: {subcommands}.", paused: "{bot} è in pausa al momento. Il proprietario del bot può riprenderlo dalla dashboard.", offHere: "Quel comando è disattivato in questo server. Chiunque abbia Gestisci server può riattivarlo.", featureOff: "Il proprietario di {bot} ha disattivato questa parte del bot, quindi non funziona in nessun server.", @@ -194,6 +199,7 @@ export const REPLIES: Record> = { youNeed: "Вам нужны эти права: {permissions}", iNeed: "Мне нужны эти права: {permissions}", iNeedIn: "Мне нужны эти права в {channel}: {permissions}", + pickSubcommand: "После названия этой команды нужно указать одно из: {subcommands}.", paused: "{bot} сейчас на паузе. Владелец бота может возобновить его в панели.", offHere: "Эта команда выключена на этом сервере. Любой с правом «Управлять сервером» может включить её снова.", featureOff: "Владелец {bot} выключил эту часть бота, поэтому она не работает ни на одном сервере.", @@ -227,6 +233,7 @@ export const REPLIES: Record> = { youNeed: "Je hebt deze rechten nodig: {permissions}", iNeed: "Ik heb deze rechten nodig: {permissions}", iNeedIn: "Ik heb deze rechten nodig in {channel}: {permissions}", + pickSubcommand: "Die opdracht heeft na de naam een van deze nodig: {subcommands}.", paused: "{bot} is nu gepauzeerd. De eigenaar van de bot kan hem in het dashboard hervatten.", offHere: "Die opdracht staat uit in deze server. Iedereen met Server beheren kan hem weer aanzetten.", featureOff: "De eigenaar van {bot} heeft dit deel van de bot uitgezet, dus het werkt in geen enkele server.", @@ -262,6 +269,7 @@ export const REPLIES: Record> = { youNeed: "次の権限が必要です:{permissions}", iNeed: "ボットに次の権限が必要です:{permissions}", iNeedIn: "{channel} でボットに次の権限が必要です:{permissions}", + pickSubcommand: "このコマンドの名前の後に、次のいずれかを付けてください:{subcommands}", paused: "{bot} は現在一時停止中です。ボットのオーナーがダッシュボードから再開できます。", offHere: "このコマンドはこのサーバーでオフになっています。「サーバー管理」の権限があれば誰でもオンに戻せます。", featureOff: "{bot}のオーナーがボットのこの機能をオフにしているため、どのサーバーでも使えません。", diff --git a/src/core/checks.ts b/src/core/checks.ts index fb8188d4..69c40cd0 100644 --- a/src/core/checks.ts +++ b/src/core/checks.ts @@ -3,7 +3,8 @@ import { type BotLanguage } from "@config/languages"; import { theme } from "@config/theme"; import { botName } from "@core/brand"; import { type TestifyClient } from "@core/client"; -import { type Command, type CommandInput, type Subcommand } from "@core/command"; +import { type Command, type CommandInput, type Subcommand, subcommandsOf } from "@core/command"; +import { UserFacingError } from "@core/errors"; import { durationIn, eitherOf, permissionList, readerLanguage, say } from "@core/language"; import { findBlacklistEntry } from "@database/repositories/blacklistRepository"; import { disabledGlobally, disabledInGuild } from "@database/repositories/commandToggleRepository"; @@ -63,10 +64,16 @@ export async function runChecks( const switchedOff = await checkSwitchedOff(command.name, interaction.guildId, language); if (switchedOff !== null) return switchedOff; - const musicOff = await checkMusicSystem(interaction, command); + const subcommand = chosenSubcommand(interaction, command); + if (subcommand === undefined) { + const names = subcommandsOf(command).map((sub) => `\`${sub.name}\``); + return say(language, "pickSubcommand", { subcommands: eitherOf(language, names) }); + } + + const musicOff = await checkMusicSystem(interaction, command, subcommand); if (musicOff !== null) return musicOff; - const casinoClosed = await checkCasino(interaction, command); + const casinoClosed = await checkCasino(interaction, command, subcommand); if (casinoClosed !== null) return casinoClosed; if (command.guildOnly && !interaction.guild) { @@ -80,7 +87,7 @@ export async function runChecks( } const member = interaction.member; - const needed = [...(command.permissions ?? []), ...(chosenSubcommand(interaction, command)?.permissions ?? [])]; + const needed = [...(command.permissions ?? []), ...(subcommand?.permissions ?? [])]; if (needed.length > 0 && member !== null) { const missing = permissionsOf(member).missing(new PermissionsBitField(needed)); @@ -234,11 +241,18 @@ async function checkSwitchedOff(name: string, guildId: string | null, language: return null; } -/** Which subcommand was used, or null — a command with no subcommands answers null the contract cannot express. */ -function chosenSubcommand(interaction: CommandInput, command: Command): Subcommand | null { +/** Which subcommand was used: null for a command without them, undefined for a prefix run that named none it has. */ +function chosenSubcommand(interaction: CommandInput, command: Command): Subcommand | null | undefined { if (command.subcommands === undefined) return null; - const chosen: string | null = interaction.options.getSubcommand(false); + let chosen: string | null; + try { + chosen = interaction.options.getSubcommand(false); + } catch (error) { + // The prefix reader throws on its first read when the words typed name no subcommand. + if (error instanceof UserFacingError) return undefined; + throw error; + } return command.subcommands.find((subcommand) => subcommand.name === chosen) ?? null; } @@ -262,10 +276,14 @@ function roleIdsOf(member: CommandInput["member"]): string[] { * The music system's switch and DJ roles, gated here so no music command can forget them; `/music system` is the way * back in. */ -async function checkMusicSystem(interaction: CommandInput, command: Command): Promise { +async function checkMusicSystem( + interaction: CommandInput, + command: Command, + subcommand: Subcommand | null, +): Promise { if (command.category !== "music" || interaction.guildId === null) return null; - if (chosenSubcommand(interaction, command)?.name === MUSIC_SYSTEM_SUBCOMMAND) return null; + if (subcommand?.name === MUSIC_SYSTEM_SUBCOMMAND) return null; const settings = normaliseMusicSettings(await getMusicSettings(interaction.guildId)); const member = interaction.member; @@ -277,10 +295,14 @@ async function checkMusicSystem(interaction: CommandInput, command: Command): Pr } /** The casino's switch and its per-game switches; settings and stats stay reachable while it is closed. */ -async function checkCasino(interaction: CommandInput, command: Command): Promise { +async function checkCasino( + interaction: CommandInput, + command: Command, + subcommand: Subcommand | null, +): Promise { if (command.name !== CASINO_COMMAND || interaction.guildId === null) return null; - const chosen = chosenSubcommand(interaction, command)?.name ?? null; + const chosen = subcommand?.name ?? null; if (chosen === CASINO_SETTINGS_SUBCOMMAND || chosen === CASINO_STATS_SUBCOMMAND) return null; return casinoRefusal( diff --git a/src/core/prefix.ts b/src/core/prefix.ts index 4a28bf87..3faf8e9e 100644 --- a/src/core/prefix.ts +++ b/src/core/prefix.ts @@ -80,7 +80,7 @@ class PrefixOptions extends OptionReader implements CommandInputOptions { this.args = args; } - /** Parsed on first use, so a missing subcommand fails inside the command's error boundary. */ + /** Parsed on first use; a missing or unknown subcommand throws, which `runChecks` answers as a refusal. */ private get state(): { values: Map; subcommand: string | null; declared: CommandOption[] } { if (this.parsed !== null) return this.parsed; diff --git a/tests/core/checks.test.ts b/tests/core/checks.test.ts index aca46a13..6f913536 100644 --- a/tests/core/checks.test.ts +++ b/tests/core/checks.test.ts @@ -11,8 +11,9 @@ import { runChecks, } from "@core/checks"; import { defineCommand } from "@core/command"; +import { PrefixInteraction } from "@core/prefix"; import { refusalEmbed } from "@lib/discord/embeds.util"; -import { createMockClient, createMockInteraction, OWNER_ID, USER_ID } from "@tests/helpers/mocks"; +import { createMockClient, createMockInteraction, createMockMessage, OWNER_ID, USER_ID } from "@tests/helpers/mocks"; const findBlacklistEntry = jest.fn, []>(() => Promise.resolve(null), @@ -350,6 +351,49 @@ describe("the music system's own switch", () => { }); }); +describe("a prefix run that names no subcommand the command has", () => { + const role = defineCommand({ + name: "role", + description: "Roles.", + category: "moderation", + cooldown: 5_000, + subcommands: [ + { name: "add", description: "Adds.", run: jest.fn() }, + { name: "remove", description: "Removes.", run: jest.fn() }, + ], + }); + const casino = defineCommand({ + name: "casino", + description: "Games.", + category: "casino", + subcommands: [{ name: "roulette", description: "Spins.", run: jest.fn() }], + }); + const typed = (command: typeof role, args: string[]) => new PrefixInteraction(createMockMessage(), command, args); + + /** `t?role @someone` threw out of the checks, so nobody was answered and the handler logged an error. */ + it("is refused with the subcommands to pick from, and costs no cooldown", async () => { + const client = createMockClient(); + + expect(await runChecks(typed(role, [`<@${USER_ID}>`]), role, client)).toBe( + "That command needs one of these after its name: `add` or `remove`.", + ); + expect(await runChecks(typed(role, []), role, client)).toContain("`add` or `remove`"); + expect(await runChecks(typed(role, ["add", `<@${USER_ID}>`]), role, client)).toBeNull(); + }); + + /** The music and casino gates read the subcommand before the permissions do, and threw there first. */ + it("is refused by the commands the music and casino gates look at too", async () => { + const refusals = await Promise.all( + [music, casino].map((command) => runChecks(typed(command, ["nonsense"]), command, createMockClient())), + ); + + expect(refusals).toEqual([ + "That command needs one of these after its name: `skip` or `system`.", + "That command needs one of these after its name: `roulette`.", + ]); + }); +}); + describe("a subcommand that needs its own permissions", () => { /** `/music` is open to everybody, so the permission belongs to the one subcommand rather than the command. */ it("refuses somebody who cannot manage the server", async () => {