From 79f255dcf42a6f505f028794bf46e6e6f5b2f8a5 Mon Sep 17 00:00:00 2001 From: David Lindsay Date: Mon, 1 Jun 2026 13:22:12 -0700 Subject: [PATCH] feat: add slash command equivalents for all commands Implement /s, /score, /trending, /leader, and /configdump as Discord slash commands alongside the existing !-prefixed commands, which continue to work. - commands.js: SlashCommandBuilder schema + handleInteraction dispatcher, reusing the same primitives as the message path - deploy-commands.js: registration script (npm run deploy), guild-scoped via GUILD_ID or global; derives the app ID from the bot token - index.js: interactionCreate listener with error handling - config.js: add GuildId (GUILD_ID) - replacer.js: export isBlockedPhrase for the /s blocked-phrase gate - __tests__/commands.test.js: handler coverage for every command - CLAUDE.md: document slash commands, modules, and GUILD_ID Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 41 +++++++++- __tests__/commands.test.js | 137 +++++++++++++++++++++++++++++++ __tests__/config.test.js | 3 +- commands.js | 164 +++++++++++++++++++++++++++++++++++++ config.js | 5 +- deploy-commands.js | 35 ++++++++ index.js | 19 ++++- package.json | 1 + replacer.js | 3 +- 9 files changed, 403 insertions(+), 5 deletions(-) create mode 100644 __tests__/commands.test.js create mode 100644 commands.js create mode 100644 deploy-commands.js diff --git a/CLAUDE.md b/CLAUDE.md index a0fa7a2..4311d9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,12 @@ phrases and people, and lets users quote-edit each other's messages. | `!leader ` | Top 5 users who received that emoji reaction in the last 30 days | | `!configDump` | Dumps sanitised config JSON (requires `ALLOW_CONFIG_DUMP=true`) | +Every command is also available as a Discord **slash command** with identical +behaviour: `/s search: replacement:` (omit `replacement` to delete), +`/score phrase:`, `/trending`, `/leader emoji:`, `/configdump`. The +`!`-prefixed forms still work; both routes share the same underlying logic. See +[Slash Commands](#slash-commands). + ## Passive Scoring Every message is scanned line-by-line. Lines matching these patterns adjust @@ -31,7 +37,9 @@ Scoring is case-insensitive. Phrase lookup via `!score` is also case-insensitive | File | Responsibility | |---|---| -| `index.js` | Discord client setup and command routing | +| `index.js` | Discord client setup, message/reaction/interaction routing | +| `commands.js` | Slash command definitions + `handleInteraction` dispatcher | +| `deploy-commands.js` | One-off script that registers slash commands with Discord (`npm run deploy`) | | `config.js` | Reads env vars; returns a typed, documented config object | | `db.js` | Opens the SQLite database singleton | | `scoring.js` | `processScores`, `getScore`, `getTrending` — all DB interaction; `parseScoreLine` is an internal pure helper | @@ -100,6 +108,36 @@ collisions (e.g. `!s url/link` cannot match the URL placeholder). | `LT_PLACEHOLDER` | `\uE003` | internal | Shields `<` from `removeMd` inside `stripMarkdown` | | `GT_PLACEHOLDER` | `\uE004` | internal | Shields `>` from `removeMd` inside `stripMarkdown` | +## Slash Commands + +`commands.js` defines the slash command schema (`slashCommands`, built with +`SlashCommandBuilder`) and `handleInteraction`, which dispatches a +`ChatInputCommandInteraction` to the matching handler. `index.js` wires this to +the `interactionCreate` event; unknown commands are ignored and handler errors +are caught there and surfaced as an ephemeral "something broke" reply. + +Slash command names must be lowercase, so `!configDump` becomes `/configdump`. + +**Registration**: `deploy-commands.js` (`npm run deploy`) registers the schema +with Discord — guild-scoped when `GUILD_ID` is set (instant), else global. The +application ID is derived from the bot token, so no extra env var is needed. +Re-run after changing any command definition. + +**Reuse over the `!` path**: handlers call the same primitives as `index.js` +(`replaceFirstMessage`, `getScore`, `getTrending`, `getLeaderboard`, +`parseLeaderCommand`, `registerProxyMessage`, `oneBlockedMessage`). Two notable +adaptations: + +- **`oneBlockedMessage`** expects a `message` shape, so slash handlers wrap the + interaction via `interactionAsMessage` — a triggered Easter egg replies to the + interaction directly, and the handler bails (as the `!` path does). +- **`/s`** defers (history fetch can exceed the 3s window), then routes + `replaceFirstMessage`'s `channel.send` to `interaction.editReply`, so the + bot's reply *is* the quote. `editReply` returns the sent Message, which is + passed to `registerProxyMessage` for reaction-credit proxying — identical to + the `!s` flow. Blocked phrases get a private (ephemeral) "nope" since a slash + command must respond, whereas the `!s` path silently ignores them. + ## `reactions.js` — Emoji Reaction Leaderboard The bot listens to `messageReactionAdd` and `messageReactionRemove` events and @@ -147,6 +185,7 @@ unchanged. | Variable | Default | Description | |---|---|---| | `TOKEN` | — | Discord bot token (required) | +| `GUILD_ID` | — | Server ID to register slash commands to (instant). Unset → global registration (~1h to propagate) | | `SCORE_DATABASE` | `./score.db3` | Path to the SQLite database file | | `MESSAGE_FETCH_COUNT` | `50` | How many recent messages `!s` searches | | `ALLOW_CONFIG_DUMP` | `false` | Enables `!configDump` | diff --git a/__tests__/commands.test.js b/__tests__/commands.test.js new file mode 100644 index 0000000..62ea61e --- /dev/null +++ b/__tests__/commands.test.js @@ -0,0 +1,137 @@ +// dotenv is mocked so the real .env can't leak values (e.g. ALLOW_CONFIG_DUMP) +// into these tests. +jest.mock('dotenv'); + +describe('commands (slash)', () => { + beforeEach(() => { + process.env.SCORE_DATABASE = ':memory:'; + // Keep the Easter egg out of the way so handler output is deterministic. + process.env.DISABLE_ONE_BLOCKED_MESSAGE = 'true'; + }); + + // Builds a fake ChatInputCommandInteraction. `options` maps option name → value; + // any name not present resolves to null, matching discord.js for absent options. + const makeInteraction = (commandName, options = {}, { history = [] } = {}) => ({ + commandName, + deferred: false, + replied: false, + user: { id: 'user1', username: 'tester', toString: () => '<@user1>' }, + guild: { members: { fetch: jest.fn() } }, + channel: { + messages: { fetch: jest.fn().mockResolvedValue(new Map(history.map((m, i) => [String(i), m]))) }, + }, + options: { getString: (name) => (name in options ? options[name] : null) }, + reply: jest.fn().mockResolvedValue({ id: 'reply-msg' }), + editReply: jest.fn().mockResolvedValue({ id: 'edited-msg' }), + deferReply: jest.fn().mockResolvedValue(), + }); + + const userMessage = (content) => ({ content, author: { bot: false, toString: () => '<@orig>' } }); + + describe('slashCommands', () => { + it('exposes the expected command names', () => { + const { slashCommands } = require('../commands'); + const names = slashCommands.map(c => c.name).sort(); + expect(names).toEqual(['configdump', 'leader', 's', 'score', 'trending']); + }); + }); + + describe('/trending', () => { + it('replies with the trending summary', async () => { + const { handleInteraction } = require('../commands'); + const interaction = makeInteraction('trending'); + await handleInteraction(interaction); + expect(interaction.reply).toHaveBeenCalledWith(expect.stringContaining('Trending')); + }); + }); + + describe('/score', () => { + it('replies with the phrase score', async () => { + const { handleInteraction } = require('../commands'); + const interaction = makeInteraction('score', { phrase: 'kirby' }); + await handleInteraction(interaction); + expect(interaction.reply).toHaveBeenCalledWith('Score *kirby*: 0'); + }); + }); + + describe('/leader', () => { + it('defers, then edits with the leaderboard and suppresses pings', async () => { + const { handleInteraction } = require('../commands'); + const interaction = makeInteraction('leader', { emoji: '👍' }); + await handleInteraction(interaction); + expect(interaction.deferReply).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith({ + content: 'Who is one 👍 message', + allowedMentions: { parse: [] }, + }); + }); + }); + + describe('/s', () => { + it('quotes a matching message and registers a reaction proxy', async () => { + const { handleInteraction } = require('../commands'); + const interaction = makeInteraction( + 's', + { search: 'world', replacement: 'there' }, + { history: [userMessage('hello world')] } + ); + await handleInteraction(interaction); + expect(interaction.deferReply).toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith('<@orig> hello **there**'); + }); + + it('reports when nothing matches', async () => { + const { handleInteraction } = require('../commands'); + const interaction = makeInteraction( + 's', + { search: 'nope', replacement: 'x' }, + { history: [userMessage('hello world')] } + ); + await handleInteraction(interaction); + expect(interaction.editReply).toHaveBeenCalledWith('<@user1> nobody said that, dumb ass'); + }); + + it('deletes the matched phrase when no replacement is given', async () => { + const { handleInteraction } = require('../commands'); + const interaction = makeInteraction( + 's', + { search: 'world' }, // replacement absent → delete + { history: [userMessage('hello world')] } + ); + await handleInteraction(interaction); + expect(interaction.editReply).toHaveBeenCalledWith('<@orig> hello '); + }); + }); + + describe('/configdump', () => { + it('replies ephemerally when config dump is disabled', async () => { + process.env.ALLOW_CONFIG_DUMP = 'false'; + const { handleInteraction } = require('../commands'); + const { MessageFlags } = require('discord.js'); + const interaction = makeInteraction('configdump'); + await handleInteraction(interaction); + expect(interaction.reply).toHaveBeenCalledWith({ + content: 'Config dump is disabled.', + flags: MessageFlags.Ephemeral, + }); + }); + + it('dumps sanitised config (no token) when enabled', async () => { + process.env.ALLOW_CONFIG_DUMP = 'true'; + process.env.TOKEN = 'super-secret'; + const { handleInteraction } = require('../commands'); + const interaction = makeInteraction('configdump'); + await handleInteraction(interaction); + const sent = interaction.reply.mock.calls[0][0]; + expect(sent).toContain('```json'); + expect(sent).not.toContain('super-secret'); + }); + }); + + it('ignores unknown commands without throwing', async () => { + const { handleInteraction } = require('../commands'); + const interaction = makeInteraction('nonexistent'); + await expect(handleInteraction(interaction)).resolves.toBeUndefined(); + expect(interaction.reply).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/config.test.js b/__tests__/config.test.js index bcb3020..73792f4 100644 --- a/__tests__/config.test.js +++ b/__tests__/config.test.js @@ -22,7 +22,8 @@ describe('config module', () => { ScoreDatabase: './score.db3', SearchPhrasesToBlock: [], TheRealests: ['kerouac5'], - Token: undefined + Token: undefined, + GuildId: undefined }); }); diff --git a/commands.js b/commands.js new file mode 100644 index 0000000..0d848cc --- /dev/null +++ b/commands.js @@ -0,0 +1,164 @@ +const { SlashCommandBuilder, MessageFlags } = require('discord.js'); +const config = require('./config').getConfig(); +const { replaceFirstMessage, normalizeUnicode, isBlockedPhrase } = require('./replacer'); +const { getScore, getTrending } = require('./scoring'); +const { getLeaderboard, parseLeaderCommand, registerProxyMessage } = require('./reactions'); +const { oneBlockedMessage } = require('./one-blocked-message'); + +const getCleansedConfig = () => ({ ...config, Token: undefined }); + +// --------------------------------------------------------------------------- +// Command definitions — registered with Discord by deploy-commands.js. +// Names must be lowercase (Discord requirement), so !configDump → /configdump. +// --------------------------------------------------------------------------- + +const slashCommands = [ + new SlashCommandBuilder() + .setName('s') + .setDescription('Quote the most recent matching message with a replacement') + .addStringOption(o => o + .setName('search') + .setDescription('Text to find in a recent message') + .setRequired(true)) + .addStringOption(o => o + .setName('replacement') + .setDescription('Replacement text (omit to delete the matched phrase)') + .setRequired(false)), + + new SlashCommandBuilder() + .setName('score') + .setDescription('Show a phrase\'s lifetime score') + .addStringOption(o => o + .setName('phrase') + .setDescription('The phrase to look up') + .setRequired(true)), + + new SlashCommandBuilder() + .setName('trending') + .setDescription('Top and bottom scoring phrases from the last 7 days'), + + new SlashCommandBuilder() + .setName('leader') + .setDescription('Top users who received an emoji reaction in the last 30 days') + .addStringOption(o => o + .setName('emoji') + .setDescription('The emoji to rank by') + .setRequired(true)), + + new SlashCommandBuilder() + .setName('configdump') + .setDescription('Dump sanitised config JSON (requires ALLOW_CONFIG_DUMP)'), +]; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/** + * Adapts a ChatInputCommandInteraction to the minimal `message` shape that + * `oneBlockedMessage` expects, so the Easter egg works identically for slash + * commands. A triggered Easter egg replies to the interaction directly. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +function interactionAsMessage(interaction) { + return { + author: { + username: interaction.user.username, + toString: () => interaction.user.toString(), + }, + channel: { send: (msg) => interaction.reply(msg) }, + }; +} + +async function handleReplace(interaction) { + // Roll the Easter egg first; if it fires it has already replied, so bail. + if (oneBlockedMessage(interactionAsMessage(interaction))) return; + + const search = normalizeUnicode(interaction.options.getString('search', true)); + const rawReplacement = interaction.options.getString('replacement'); + const replacement = rawReplacement === null ? undefined : normalizeUnicode(rawReplacement); + + if (isBlockedPhrase(search) || (replacement !== undefined && isBlockedPhrase(replacement))) { + // The message-based command silently ignores blocked phrases; a slash + // command must respond, so acknowledge privately without channel spam. + await interaction.reply({ content: 'nope', flags: MessageFlags.Ephemeral }); + return; + } + + await interaction.deferReply(); + const history = await interaction.channel.messages.fetch({ limit: config.MessageFetchCount }); + // replaceFirstMessage sends the quote via channel.send; routing that to + // editReply makes the bot's reply itself the quote and yields its Message + // so the reaction-credit proxy can be registered. + const replyChannel = { send: (content) => interaction.editReply(content) }; + const sentMsg = await replaceFirstMessage(history.values(), search, replacement, replyChannel); + + if (!sentMsg) { + await interaction.editReply(`${interaction.user} nobody said that, dumb ass`); + } else { + registerProxyMessage(sentMsg.id, interaction.user.id); + } +} + +async function handleScore(interaction) { + if (oneBlockedMessage(interactionAsMessage(interaction))) return; + + const phrase = interaction.options.getString('phrase', true).trim(); + await interaction.reply(`Score *${phrase}*: ${getScore(phrase)}`); +} + +async function handleTrending(interaction) { + await interaction.reply(getTrending(5)); +} + +async function handleLeader(interaction) { + const parsed = parseLeaderCommand(`!leader ${interaction.options.getString('emoji', true)}`); + if (!parsed) { + await interaction.reply({ content: 'Usage: /leader ', flags: MessageFlags.Ephemeral }); + return; + } + + await interaction.deferReply(); + // Resolve each ID to the user's server display name. Plain text — not a + // `<@id>` mention — so the response doesn't ping; allowedMentions is a + // safety belt in case a display name contains literal "@everyone". + const resolveName = async (id) => { + try { + return (await interaction.guild.members.fetch(id)).displayName; + } catch { + return 'unknown user'; + } + }; + const board = await getLeaderboard(parsed.key, parsed.display, resolveName); + await interaction.editReply({ content: board, allowedMentions: { parse: [] } }); +} + +async function handleConfigDump(interaction) { + if (config.AllowConfigDump !== true) { + await interaction.reply({ content: 'Config dump is disabled.', flags: MessageFlags.Ephemeral }); + return; + } + await interaction.reply(`Config: \`\`\`json\n${JSON.stringify(getCleansedConfig(), null, 2)}\n\`\`\``); +} + +const handlers = { + s: handleReplace, + score: handleScore, + trending: handleTrending, + leader: handleLeader, + configdump: handleConfigDump, +}; + +/** + * Routes a chat-input command interaction to its handler. Unknown commands are + * ignored. Errors propagate to the caller, which is responsible for replying. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleInteraction(interaction) { + const handler = handlers[interaction.commandName]; + if (handler) await handler(interaction); +} + +module.exports = { slashCommands, handleInteraction }; diff --git a/config.js b/config.js index a24950f..08b676d 100644 --- a/config.js +++ b/config.js @@ -36,7 +36,10 @@ const getConfig = () => { DreadInactivityMs: (dreadInactivityHours > 0 ? dreadInactivityHours : 2) * 60 * 60 * 1000, /** Discord bot token. Required. */ - Token: process.env.TOKEN + Token: process.env.TOKEN, + + /** Guild (server) ID to register slash commands to. Unset → register globally. */ + GuildId: process.env.GUILD_ID }; }; diff --git a/deploy-commands.js b/deploy-commands.js new file mode 100644 index 0000000..fe1745f --- /dev/null +++ b/deploy-commands.js @@ -0,0 +1,35 @@ +// Registers the bot's slash commands with Discord. Run once after changing any +// command definition in commands.js: `npm run deploy` +// +// If GUILD_ID is set, commands are registered to that single guild and appear +// instantly — ideal for a single friend-group server. Otherwise they register +// globally, which can take up to ~1 hour to propagate. +require('dotenv').config(); +const { REST, Routes } = require('discord.js'); +const { slashCommands } = require('./commands'); +const config = require('./config').getConfig(); + +(async () => { + if (!config.Token) { + console.error('No TOKEN set — add it to your .env file.'); + process.exit(1); + } + + const rest = new REST({ version: '10' }).setToken(config.Token); + const body = slashCommands.map(c => c.toJSON()); + + // The application (client) ID is derived from the token so no extra env var + // is needed. + const app = await rest.get(Routes.currentApplication()); + const route = config.GuildId + ? Routes.applicationGuildCommands(app.id, config.GuildId) + : Routes.applicationCommands(app.id); + + const data = await rest.put(route, { body }); + console.log( + `Registered ${data.length} slash command(s) ${config.GuildId ? `to guild ${config.GuildId}` : 'globally'}.` + ); +})().catch((err) => { + console.error('Failed to register slash commands:', err); + process.exit(1); +}); diff --git a/index.js b/index.js index e0c7f22..19196b7 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,11 @@ -const { Client, GatewayIntentBits, Partials } = require('discord.js'); +const { Client, GatewayIntentBits, Partials, MessageFlags } = require('discord.js'); const config = require('./config').getConfig(); const { replaceFirstMessage, splitReplaceCommand } = require('./replacer'); const { processScores, getScore, getTrending } = require('./scoring'); const { recordReaction, removeReaction, getLeaderboard, parseLeaderCommand, registerProxyMessage } = require('./reactions'); const { oneBlockedMessage } = require('./one-blocked-message'); const { recordActivity } = require('./dread'); +const { handleInteraction } = require('./commands'); const getCleansedConfig = () => ({ ...config, Token: undefined }); @@ -98,6 +99,22 @@ async function withResolvedReaction(reaction, user, { fetchMessage = false, hand handler(reaction, user); } +client.on('interactionCreate', async (interaction) => { + if (!interaction.isChatInputCommand()) return; + await recordActivity(interaction.channel); + try { + await handleInteraction(interaction); + } catch (err) { + console.error('Interaction handler error:', err); + const failure = { content: 'something broke', flags: MessageFlags.Ephemeral }; + if (interaction.deferred || interaction.replied) { + interaction.editReply('something broke').catch(() => {}); + } else { + interaction.reply(failure).catch(() => {}); + } + } +}); + client.on('messageReactionAdd', (r, u) => withResolvedReaction(r, u, { fetchMessage: true, handler: recordReaction })); client.on('messageReactionRemove', (r, u) => withResolvedReaction(r, u, { fetchMessage: false, handler: removeReaction })); diff --git a/package.json b/package.json index 2ba56e6..23b8475 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "index.js", "scripts": { "start": "nodemon", + "deploy": "node deploy-commands.js", "lint": "eslint .", "test": "jest" }, diff --git a/replacer.js b/replacer.js index 47db7e7..682f716 100644 --- a/replacer.js +++ b/replacer.js @@ -275,5 +275,6 @@ module.exports = { extractUrls, extractDiscordEntities, replaceFirstMessage, - splitReplaceCommand + splitReplaceCommand, + isBlockedPhrase };