Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ phrases and people, and lets users quote-edit each other's messages.
| `!leader <emoji>` | 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:<text> replacement:<text>` (omit `replacement` to delete),
`/score phrase:<text>`, `/trending`, `/leader emoji:<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
Expand All @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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` |
Expand Down
137 changes: 137 additions & 0 deletions __tests__/commands.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 2 additions & 1 deletion __tests__/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ describe('config module', () => {
ScoreDatabase: './score.db3',
SearchPhrasesToBlock: [],
TheRealests: ['kerouac5'],
Token: undefined
Token: undefined,
GuildId: undefined
});
});

Expand Down
164 changes: 164 additions & 0 deletions commands.js
Original file line number Diff line number Diff line change
@@ -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 <emoji>', 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 };
5 changes: 4 additions & 1 deletion config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
};

Expand Down
Loading
Loading