diff --git a/migrations/004_reaction_roles.cjs b/migrations/004_reaction_roles.cjs new file mode 100644 index 00000000..db59f1e3 --- /dev/null +++ b/migrations/004_reaction_roles.cjs @@ -0,0 +1,54 @@ +'use strict'; + +/** + * Migration: Reaction Role Menus + * + * Creates tables to persist reaction-role mappings across bot restarts. + * + * - reaction_role_menus: One row per "reaction role" message posted in Discord + * - reaction_role_entries: One row per emoji→role mapping attached to a menu + */ + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + // ── reaction_role_menus ──────────────────────────────────────────── + pgm.sql(` + CREATE TABLE IF NOT EXISTS reaction_role_menus ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + message_id TEXT NOT NULL, + title TEXT NOT NULL DEFAULT 'React to get a role', + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (guild_id, message_id) + ) + `); + pgm.sql( + 'CREATE INDEX IF NOT EXISTS idx_reaction_role_menus_guild ON reaction_role_menus(guild_id)', + ); + pgm.sql( + 'CREATE UNIQUE INDEX IF NOT EXISTS idx_reaction_role_menus_message ON reaction_role_menus(message_id)', + ); + + // ── reaction_role_entries ────────────────────────────────────────── + pgm.sql(` + CREATE TABLE IF NOT EXISTS reaction_role_entries ( + id SERIAL PRIMARY KEY, + menu_id INTEGER NOT NULL REFERENCES reaction_role_menus(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + role_id TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (menu_id, emoji) + ) + `); + pgm.sql( + 'CREATE INDEX IF NOT EXISTS idx_reaction_role_entries_menu ON reaction_role_entries(menu_id)', + ); +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.sql('DROP TABLE IF EXISTS reaction_role_entries'); + pgm.sql('DROP TABLE IF EXISTS reaction_role_menus'); +}; diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..87273d9f --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/bill/volvox-bot/node_modules \ No newline at end of file diff --git a/src/commands/reactionrole.js b/src/commands/reactionrole.js new file mode 100644 index 00000000..bb724fcf --- /dev/null +++ b/src/commands/reactionrole.js @@ -0,0 +1,370 @@ +/** + * Reaction Role Command + * + * Slash command for managing reaction-role menus. + * Requires Manage Roles permission. + * + * Subcommands: + * /reactionrole create – Post a new reaction-role menu message + * /reactionrole add – Add an emoji→role mapping to a menu + * /reactionrole remove – Remove an emoji mapping from a menu + * /reactionrole delete – Delete an entire reaction-role menu + * /reactionrole list – List all reaction-role menus in this server + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/162 + */ + +import { PermissionFlagsBits, SlashCommandBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { warn } from '../logger.js'; +import { + buildReactionRoleEmbed, + deleteMenu, + findMenuByMessageId, + getEntriesForMenu, + insertReactionRoleMenu, + listMenusForGuild, + removeReactionRoleEntry, + upsertReactionRoleEntry, +} from '../modules/reactionRoles.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('reactionrole') + .setDescription('Manage reaction-role menus') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) + // ── create ────────────────────────────────────────────────────────── + .addSubcommand((sub) => + sub + .setName('create') + .setDescription('Post a new reaction-role menu in a channel') + .addStringOption((opt) => opt.setName('title').setDescription('Menu title').setRequired(true)) + .addChannelOption((opt) => + opt + .setName('channel') + .setDescription('Channel to post the menu in (defaults to current channel)') + .setRequired(false), + ) + .addStringOption((opt) => + opt + .setName('description') + .setDescription('Optional description shown above the role list') + .setRequired(false), + ), + ) + // ── add ───────────────────────────────────────────────────────────── + .addSubcommand((sub) => + sub + .setName('add') + .setDescription('Add an emoji→role mapping to an existing menu') + .addStringOption((opt) => + opt + .setName('message_id') + .setDescription('ID of the reaction-role menu message') + .setRequired(true), + ) + .addStringOption((opt) => + opt.setName('emoji').setDescription('Emoji to react with').setRequired(true), + ) + .addRoleOption((opt) => + opt + .setName('role') + .setDescription('Role to grant when the emoji is used') + .setRequired(true), + ), + ) + // ── remove ────────────────────────────────────────────────────────── + .addSubcommand((sub) => + sub + .setName('remove') + .setDescription('Remove an emoji mapping from a menu') + .addStringOption((opt) => + opt + .setName('message_id') + .setDescription('ID of the reaction-role menu message') + .setRequired(true), + ) + .addStringOption((opt) => + opt.setName('emoji').setDescription('Emoji mapping to remove').setRequired(true), + ), + ) + // ── delete ────────────────────────────────────────────────────────── + .addSubcommand((sub) => + sub + .setName('delete') + .setDescription('Delete an entire reaction-role menu (and optionally its Discord message)') + .addStringOption((opt) => + opt + .setName('message_id') + .setDescription('ID of the reaction-role menu message') + .setRequired(true), + ), + ) + // ── list ──────────────────────────────────────────────────────────── + .addSubcommand((sub) => + sub.setName('list').setDescription('List all reaction-role menus in this server'), + ); + +/** + * Execute /reactionrole + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + + const pool = getPool(); + if (!pool) { + await safeEditReply(interaction, { content: '❌ Database is not available.' }); + return; + } + + const sub = interaction.options.getSubcommand(); + + if (sub === 'create') return handleCreate(interaction); + if (sub === 'add') return handleAdd(interaction); + if (sub === 'remove') return handleRemove(interaction); + if (sub === 'delete') return handleDelete(interaction); + if (sub === 'list') return handleList(interaction); +} + +// ── Subcommand handlers ─────────────────────────────────────────────────────── + +/** + * /reactionrole create + */ +async function handleCreate(interaction) { + const title = interaction.options.getString('title'); + const description = interaction.options.getString('description'); + const targetChannel = interaction.options.getChannel('channel') ?? interaction.channel; + + if (!targetChannel?.isTextBased?.()) { + await safeEditReply(interaction, { content: '❌ Target channel must be a text channel.' }); + return; + } + + // Post the embed + const embed = buildReactionRoleEmbed(title, description, []); + let postedMessage; + try { + postedMessage = await targetChannel.send({ embeds: [embed] }); + } catch (err) { + warn('reactionrole create: could not send message', { error: err?.message }); + await safeEditReply(interaction, { + content: `❌ Failed to post the menu in <#${targetChannel.id}>. Make sure I have Send Messages permission there.`, + }); + return; + } + + // Persist to DB + await insertReactionRoleMenu( + interaction.guildId, + targetChannel.id, + postedMessage.id, + title, + description, + ); + + await safeEditReply(interaction, { + content: + `✅ Reaction-role menu created in <#${targetChannel.id}>!\n` + + `Use \`/reactionrole add\` with message ID \`${postedMessage.id}\` to add emoji→role mappings.`, + }); +} + +/** + * /reactionrole add + */ +async function handleAdd(interaction) { + const messageId = interaction.options.getString('message_id').trim(); + const emojiInput = interaction.options.getString('emoji').trim(); + const role = interaction.options.getRole('role'); + + // Validate the menu exists + const menu = await findMenuByMessageId(messageId); + if (!menu) { + await safeEditReply(interaction, { + content: `❌ No reaction-role menu found with message ID \`${messageId}\`. Did you use the right ID?`, + }); + return; + } + + // Guard: guild ownership + if (menu.guild_id !== interaction.guildId) { + await safeEditReply(interaction, { content: '❌ That menu does not belong to this server.' }); + return; + } + + // Guard: bot must be able to assign the role + const botMember = await interaction.guild.members.fetchMe().catch(() => null); + if (botMember && role.position >= botMember.roles.highest.position) { + await safeEditReply(interaction, { + content: `❌ I can't assign **${role.name}** — it's higher than (or equal to) my highest role. Move my role above it first.`, + }); + return; + } + + // Normalise emoji to a stable string + const emojiKey = normaliseInputEmoji(emojiInput); + + await upsertReactionRoleEntry(menu.id, emojiKey, role.id); + + // Refresh the menu embed with updated entries + await refreshMenuEmbed(interaction, menu); + + // Add the reaction to the original message so users know which emojis to click + try { + const channel = await interaction.guild.channels.fetch(menu.channel_id); + if (channel?.isTextBased()) { + const msg = await channel.messages.fetch(messageId); + await msg.react(emojiKey); + } + } catch { + // Non-fatal — the mapping still works even without the bot's own reaction + } + + await safeEditReply(interaction, { + content: `✅ Added: ${emojiKey} → <@&${role.id}>`, + }); +} + +/** + * /reactionrole remove + */ +async function handleRemove(interaction) { + const messageId = interaction.options.getString('message_id').trim(); + const emojiInput = interaction.options.getString('emoji').trim(); + + const menu = await findMenuByMessageId(messageId); + if (!menu) { + await safeEditReply(interaction, { + content: `❌ No reaction-role menu found with message ID \`${messageId}\`.`, + }); + return; + } + + if (menu.guild_id !== interaction.guildId) { + await safeEditReply(interaction, { content: '❌ That menu does not belong to this server.' }); + return; + } + + const emojiKey = normaliseInputEmoji(emojiInput); + const removed = await removeReactionRoleEntry(menu.id, emojiKey); + + if (!removed) { + await safeEditReply(interaction, { + content: `❌ No mapping found for emoji \`${emojiKey}\` on that menu.`, + }); + return; + } + + // Refresh embed and remove bot's own reaction + await refreshMenuEmbed(interaction, menu); + try { + const channel = await interaction.guild.channels.fetch(menu.channel_id); + if (channel?.isTextBased()) { + const msg = await channel.messages.fetch(messageId); + const existing = msg.reactions.cache.get(emojiKey); + if (existing) await existing.remove(); + } + } catch { + // Non-fatal + } + + await safeEditReply(interaction, { + content: `✅ Removed emoji mapping \`${emojiKey}\` from the menu.`, + }); +} + +/** + * /reactionrole delete + */ +async function handleDelete(interaction) { + const messageId = interaction.options.getString('message_id').trim(); + + const menu = await findMenuByMessageId(messageId); + if (!menu) { + await safeEditReply(interaction, { + content: `❌ No reaction-role menu found with message ID \`${messageId}\`.`, + }); + return; + } + + if (menu.guild_id !== interaction.guildId) { + await safeEditReply(interaction, { content: '❌ That menu does not belong to this server.' }); + return; + } + + // Attempt to delete the Discord message + try { + const channel = await interaction.guild.channels.fetch(menu.channel_id); + if (channel?.isTextBased()) { + const msg = await channel.messages.fetch(messageId).catch(() => null); + if (msg) await msg.delete(); + } + } catch { + // Non-fatal — DB cleanup happens regardless + } + + await deleteMenu(menu.id); + + await safeEditReply(interaction, { + content: `✅ Reaction-role menu deleted (message ID \`${messageId}\`).`, + }); +} + +/** + * /reactionrole list + */ +async function handleList(interaction) { + const menus = await listMenusForGuild(interaction.guildId); + + if (menus.length === 0) { + await safeEditReply(interaction, { + content: 'No reaction-role menus found. Use `/reactionrole create` to make one.', + }); + return; + } + + const lines = menus.map((m) => `• **${m.title}** — <#${m.channel_id}> — \`${m.message_id}\``); + await safeEditReply(interaction, { + content: `**Reaction-role menus (${menus.length}):**\n${lines.join('\n')}`, + }); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Re-fetch entries and edit the menu embed in Discord to reflect current state. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {Object} menu - Menu DB row + */ +async function refreshMenuEmbed(interaction, menu) { + try { + const entries = await getEntriesForMenu(menu.id); + const embed = buildReactionRoleEmbed(menu.title, menu.description, entries); + + const channel = await interaction.guild.channels.fetch(menu.channel_id); + if (!channel?.isTextBased()) return; + + const msg = await channel.messages.fetch(menu.message_id).catch(() => null); + if (!msg) return; + + await msg.edit({ embeds: [embed] }); + } catch { + // Non-fatal — UI update is cosmetic + } +} + +/** + * Normalise a user-supplied emoji string. + * Strips surrounding colons (`:thumbsup:` → emoji literal won't match, but we keep it as-is + * since Discord custom emojis come in as `<:name:id>` format). + * + * @param {string} input + * @returns {string} + */ +function normaliseInputEmoji(input) { + return input.trim(); +} diff --git a/src/modules/events.js b/src/modules/events.js index a8b30c94..ce5bbc76 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -29,6 +29,7 @@ import { checkLinks } from './linkFilter.js'; import { handlePollVote } from './pollHandler.js'; import { handleQuietCommand, isQuietMode } from './quietMode.js'; import { checkRateLimit } from './rateLimit.js'; +import { handleReactionRoleAdd, handleReactionRoleRemove } from './reactionRoles.js'; import { handleReminderDismiss, handleReminderSnooze } from './reminderHandler.js'; import { handleXpGain } from './reputation.js'; import { handleReviewClaim } from './reviewHandler.js'; @@ -364,6 +365,16 @@ export function registerReactionHandlers(client, _config) { } } + // Reaction roles — check before the starboard early-return + try { + await handleReactionRoleAdd(reaction, user); + } catch (err) { + logError('Reaction role add handler failed', { + messageId: reaction.message.id, + error: err.message, + }); + } + if (!guildConfig.starboard?.enabled) return; try { @@ -405,6 +416,16 @@ export function registerReactionHandlers(client, _config) { } } + // Reaction roles — check before the starboard early-return + try { + await handleReactionRoleRemove(reaction, user); + } catch (err) { + logError('Reaction role remove handler failed', { + messageId: reaction.message.id, + error: err.message, + }); + } + if (!guildConfig.starboard?.enabled) return; try { diff --git a/src/modules/reactionRoles.js b/src/modules/reactionRoles.js new file mode 100644 index 00000000..94bd2e69 --- /dev/null +++ b/src/modules/reactionRoles.js @@ -0,0 +1,286 @@ +/** + * Reaction Roles Module + * + * Allows members to self-assign roles by reacting to a pinned message. + * Mappings are persisted in PostgreSQL so they survive bot restarts. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/162 + */ + +import { EmbedBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { debug, info, error as logError, warn } from '../logger.js'; + +// ── DB helpers ──────────────────────────────────────────────────────────────── + +/** + * Insert a new reaction-role menu row and return it. + * + * @param {string} guildId + * @param {string} channelId + * @param {string} messageId + * @param {string} title + * @param {string|null} description + * @returns {Promise} Inserted menu row + */ +export async function insertReactionRoleMenu(guildId, channelId, messageId, title, description) { + const pool = getPool(); + const result = await pool.query( + `INSERT INTO reaction_role_menus (guild_id, channel_id, message_id, title, description) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (guild_id, message_id) DO UPDATE + SET title = EXCLUDED.title, description = EXCLUDED.description + RETURNING *`, + [guildId, channelId, messageId, title, description ?? null], + ); + return result.rows[0]; +} + +/** + * Find a menu by its Discord message ID. + * + * @param {string} messageId + * @returns {Promise} + */ +export async function findMenuByMessageId(messageId) { + const pool = getPool(); + const result = await pool.query('SELECT * FROM reaction_role_menus WHERE message_id = $1', [ + messageId, + ]); + return result.rows[0] ?? null; +} + +/** + * List all menus for a guild. + * + * @param {string} guildId + * @returns {Promise} + */ +export async function listMenusForGuild(guildId) { + const pool = getPool(); + const result = await pool.query( + 'SELECT * FROM reaction_role_menus WHERE guild_id = $1 ORDER BY created_at DESC', + [guildId], + ); + return result.rows; +} + +/** + * Delete a menu (cascades to entries). + * + * @param {number} menuId + * @returns {Promise} True if deleted + */ +export async function deleteMenu(menuId) { + const pool = getPool(); + const result = await pool.query('DELETE FROM reaction_role_menus WHERE id = $1', [menuId]); + return (result.rowCount ?? 0) > 0; +} + +/** + * Add or replace an emoji→role entry on a menu. + * + * @param {number} menuId + * @param {string} emoji + * @param {string} roleId + * @returns {Promise} Inserted/updated entry row + */ +export async function upsertReactionRoleEntry(menuId, emoji, roleId) { + const pool = getPool(); + const result = await pool.query( + `INSERT INTO reaction_role_entries (menu_id, emoji, role_id) + VALUES ($1, $2, $3) + ON CONFLICT (menu_id, emoji) DO UPDATE SET role_id = EXCLUDED.role_id + RETURNING *`, + [menuId, emoji, roleId], + ); + return result.rows[0]; +} + +/** + * Remove an emoji mapping from a menu. + * + * @param {number} menuId + * @param {string} emoji + * @returns {Promise} + */ +export async function removeReactionRoleEntry(menuId, emoji) { + const pool = getPool(); + const result = await pool.query( + 'DELETE FROM reaction_role_entries WHERE menu_id = $1 AND emoji = $2', + [menuId, emoji], + ); + return (result.rowCount ?? 0) > 0; +} + +/** + * Get all entries for a menu. + * + * @param {number} menuId + * @returns {Promise} + */ +export async function getEntriesForMenu(menuId) { + const pool = getPool(); + const result = await pool.query( + 'SELECT * FROM reaction_role_entries WHERE menu_id = $1 ORDER BY created_at ASC', + [menuId], + ); + return result.rows; +} + +/** + * Find the role ID for a given message + emoji combination. + * + * @param {string} messageId + * @param {string} emoji + * @returns {Promise} roleId or null + */ +export async function findRoleForReaction(messageId, emoji) { + const pool = getPool(); + const result = await pool.query( + `SELECT e.role_id + FROM reaction_role_entries e + JOIN reaction_role_menus m ON m.id = e.menu_id + WHERE m.message_id = $1 AND e.emoji = $2`, + [messageId, emoji], + ); + return result.rows[0]?.role_id ?? null; +} + +// ── Embed builder ───────────────────────────────────────────────────────────── + +/** + * Build the embed that gets posted when a reaction-role menu is created. + * + * @param {string} title + * @param {string|null} description + * @param {Array<{emoji: string, role_id: string}>} entries + * @returns {EmbedBuilder} + */ +export function buildReactionRoleEmbed(title, description, entries = []) { + const embed = new EmbedBuilder().setTitle(title).setColor(0x5865f2); // Discord blurple + + const lines = entries.map((e) => `${e.emoji} → <@&${e.role_id}>`); + const bodyText = lines.length > 0 ? lines.join('\n') : '_No roles configured yet._'; + + embed.setDescription([description, description ? '\n' : '', bodyText].filter(Boolean).join('')); + + embed.setFooter({ text: 'React to this message to get or remove a role.' }); + + return embed; +} + +// ── Event handlers ──────────────────────────────────────────────────────────── + +/** + * Handle a reaction being added to a message. + * Grants the corresponding role if the message is a reaction-role menu. + * + * @param {import('discord.js').MessageReaction} reaction + * @param {import('discord.js').User} user + */ +export async function handleReactionRoleAdd(reaction, user) { + const pool = getPool(); + if (!pool) return; + + try { + const emoji = resolveEmojiString(reaction.emoji); + const messageId = reaction.message.id; + + const roleId = await findRoleForReaction(messageId, emoji); + if (!roleId) return; // Not a reaction-role menu or emoji not mapped + + const guild = reaction.message.guild; + if (!guild) return; + + const member = await guild.members.fetch(user.id).catch(() => null); + if (!member) { + warn('Reaction role: could not fetch member', { userId: user.id, guildId: guild.id }); + return; + } + + const role = + guild.roles.cache.get(roleId) ?? (await guild.roles.fetch(roleId).catch(() => null)); + if (!role) { + warn('Reaction role: role not found', { roleId, guildId: guild.id }); + return; + } + + if (member.roles.cache.has(roleId)) { + debug('Reaction role: member already has role', { userId: user.id, roleId }); + return; + } + + await member.roles.add(role, 'Reaction role assignment'); + info('Reaction role granted', { userId: user.id, roleId, guildId: guild.id }); + } catch (err) { + logError('handleReactionRoleAdd failed', { + messageId: reaction.message?.id, + userId: user?.id, + error: err?.message, + }); + } +} + +/** + * Handle a reaction being removed from a message. + * Revokes the corresponding role if the message is a reaction-role menu. + * + * @param {import('discord.js').MessageReaction} reaction + * @param {import('discord.js').User} user + */ +export async function handleReactionRoleRemove(reaction, user) { + const pool = getPool(); + if (!pool) return; + + try { + const emoji = resolveEmojiString(reaction.emoji); + const messageId = reaction.message.id; + + const roleId = await findRoleForReaction(messageId, emoji); + if (!roleId) return; + + const guild = reaction.message.guild; + if (!guild) return; + + const member = await guild.members.fetch(user.id).catch(() => null); + if (!member) { + warn('Reaction role: could not fetch member for removal', { + userId: user.id, + guildId: guild.id, + }); + return; + } + + if (!member.roles.cache.has(roleId)) { + debug('Reaction role: member does not have role to remove', { userId: user.id, roleId }); + return; + } + + await member.roles.remove(roleId, 'Reaction role removal'); + info('Reaction role revoked', { userId: user.id, roleId, guildId: guild.id }); + } catch (err) { + logError('handleReactionRoleRemove failed', { + messageId: reaction.message?.id, + userId: user?.id, + error: err?.message, + }); + } +} + +// ── Utilities ───────────────────────────────────────────────────────────────── + +/** + * Convert a Discord.js ReactionEmoji to a stable string key. + * Custom emojis use `<:name:id>` format; standard Unicode emojis use the literal character. + * + * @param {import('discord.js').ReactionEmoji} emoji + * @returns {string} + */ +export function resolveEmojiString(emoji) { + if (emoji.id) { + // Custom emoji + return emoji.animated ? `` : `<:${emoji.name}:${emoji.id}>`; + } + return emoji.name; // Unicode emoji +} diff --git a/tests/commands/reactionrole.test.js b/tests/commands/reactionrole.test.js new file mode 100644 index 00000000..b49f2439 --- /dev/null +++ b/tests/commands/reactionrole.test.js @@ -0,0 +1,345 @@ +/** + * Tests for src/commands/reactionrole.js + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeEditReply: vi.fn(async (interaction, payload) => { + if (typeof interaction?.editReply === 'function') { + return interaction.editReply(payload); + } + return payload; + }), +})); + +vi.mock('../../src/modules/reactionRoles.js', () => ({ + buildReactionRoleEmbed: vi.fn(() => ({ toJSON: () => ({}) })), + insertReactionRoleMenu: vi.fn(), + findMenuByMessageId: vi.fn(), + listMenusForGuild: vi.fn(), + deleteMenu: vi.fn(), + getEntriesForMenu: vi.fn().mockResolvedValue([]), + upsertReactionRoleEntry: vi.fn(), + removeReactionRoleEntry: vi.fn(), + resolveEmojiString: vi.fn((e) => e.name ?? e), +})); + +import { execute } from '../../src/commands/reactionrole.js'; +import { getPool } from '../../src/db.js'; +import { + deleteMenu, + findMenuByMessageId, + insertReactionRoleMenu, + listMenusForGuild, + removeReactionRoleEntry, + upsertReactionRoleEntry, +} from '../../src/modules/reactionRoles.js'; +import { safeEditReply } from '../../src/utils/safeSend.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makePool() { + const pool = {}; + getPool.mockReturnValue(pool); + return pool; +} + +function makeInteraction(subcommand, options = {}) { + const strings = options.strings ?? {}; + const channel = options.channel ?? null; + const role = options.role ?? null; + + const fakeChannel = { + id: 'ch-1', + isTextBased: () => true, + send: vi.fn().mockResolvedValue({ id: 'msg-new' }), + }; + + const fakeGuild = { + id: options.guildId ?? 'guild-1', + channels: { + fetch: vi.fn().mockResolvedValue(fakeChannel), + }, + members: { + fetchMe: vi.fn().mockResolvedValue({ + roles: { highest: { position: 100 } }, + }), + }, + }; + + return { + guildId: options.guildId ?? 'guild-1', + guild: fakeGuild, + channel: fakeChannel, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + options: { + getSubcommand: () => subcommand, + getString: (key) => strings[key] ?? null, + getChannel: (key) => (key === 'channel' ? channel : null), + getRole: (key) => (key === 'role' ? role : null), + }, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('/reactionrole command', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('database unavailable', () => { + it('replies with error when pool is null', async () => { + getPool.mockReturnValue(null); + const interaction = makeInteraction('list'); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('Database') }), + ); + }); + }); + + describe('create', () => { + it('posts a menu message and persists to DB', async () => { + makePool(); + insertReactionRoleMenu.mockResolvedValue({ id: 1 }); + + const interaction = makeInteraction('create', { + strings: { title: 'Get a Role!' }, + }); + + await execute(interaction); + + expect(interaction.guild.channels.fetch).not.toHaveBeenCalled(); // used current channel + expect(insertReactionRoleMenu).toHaveBeenCalledWith( + 'guild-1', + 'ch-1', + 'msg-new', + 'Get a Role!', + null, + ); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('msg-new') }), + ); + }); + + it('rejects non-text target channel', async () => { + makePool(); + const nonTextChannel = { id: 'vc-1', isTextBased: () => false }; + const interaction = makeInteraction('create', { + strings: { title: 'Roles' }, + channel: nonTextChannel, + }); + + await execute(interaction); + + expect(insertReactionRoleMenu).not.toHaveBeenCalled(); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('text channel') }), + ); + }); + }); + + describe('add', () => { + it('replies with error when menu not found', async () => { + makePool(); + findMenuByMessageId.mockResolvedValue(null); + + const interaction = makeInteraction('add', { + strings: { message_id: 'ghost-id', emoji: '⭐' }, + role: { id: 'r-1', name: 'Star', position: 5 }, + }); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('No reaction-role menu') }), + ); + }); + + it('rejects when menu belongs to different guild', async () => { + makePool(); + findMenuByMessageId.mockResolvedValue({ id: 1, guild_id: 'other-guild', channel_id: 'ch-1' }); + + const interaction = makeInteraction('add', { + strings: { message_id: 'msg-1', emoji: '⭐' }, + role: { id: 'r-1', name: 'Star', position: 5 }, + guildId: 'my-guild', + }); + + await execute(interaction); + + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('does not belong') }), + ); + }); + + it('upserts entry and replies success', async () => { + makePool(); + const menu = { + id: 7, + guild_id: 'guild-1', + channel_id: 'ch-1', + message_id: 'msg-1', + title: 'T', + description: null, + }; + findMenuByMessageId.mockResolvedValue(menu); + upsertReactionRoleEntry.mockResolvedValue({ id: 1 }); + + const role = { id: 'r-star', name: 'Star', position: 5 }; + const interaction = makeInteraction('add', { + strings: { message_id: 'msg-1', emoji: '⭐' }, + role, + }); + + await execute(interaction); + + expect(upsertReactionRoleEntry).toHaveBeenCalledWith(7, '⭐', 'r-star'); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('Added') }), + ); + }); + }); + + describe('remove', () => { + it('replies with error when menu not found', async () => { + makePool(); + findMenuByMessageId.mockResolvedValue(null); + const interaction = makeInteraction('remove', { + strings: { message_id: 'ghost', emoji: '⭐' }, + }); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('No reaction-role menu') }), + ); + }); + + it('replies with error when emoji mapping not found', async () => { + makePool(); + findMenuByMessageId.mockResolvedValue({ + id: 1, + guild_id: 'guild-1', + channel_id: 'ch-1', + message_id: 'msg-1', + }); + removeReactionRoleEntry.mockResolvedValue(false); + + const interaction = makeInteraction('remove', { + strings: { message_id: 'msg-1', emoji: '🦄' }, + }); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('No mapping found') }), + ); + }); + + it('removes entry and replies success', async () => { + makePool(); + findMenuByMessageId.mockResolvedValue({ + id: 1, + guild_id: 'guild-1', + channel_id: 'ch-1', + message_id: 'msg-1', + title: 'T', + description: null, + }); + removeReactionRoleEntry.mockResolvedValue(true); + + const interaction = makeInteraction('remove', { + strings: { message_id: 'msg-1', emoji: '⭐' }, + }); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('Removed') }), + ); + }); + }); + + describe('delete', () => { + it('replies with error when menu not found', async () => { + makePool(); + findMenuByMessageId.mockResolvedValue(null); + const interaction = makeInteraction('delete', { strings: { message_id: 'ghost' } }); + await execute(interaction); + expect(deleteMenu).not.toHaveBeenCalled(); + }); + + it('deletes menu and replies success', async () => { + makePool(); + findMenuByMessageId.mockResolvedValue({ + id: 3, + guild_id: 'guild-1', + channel_id: 'ch-1', + message_id: 'msg-del', + }); + deleteMenu.mockResolvedValue(true); + + const interaction = makeInteraction('delete', { strings: { message_id: 'msg-del' } }); + // Mock the channel.messages.fetch chain + const mockMsg = { delete: vi.fn().mockResolvedValue(undefined) }; + interaction.guild.channels.fetch.mockResolvedValue({ + isTextBased: () => true, + messages: { fetch: vi.fn().mockResolvedValue(mockMsg) }, + }); + + await execute(interaction); + expect(deleteMenu).toHaveBeenCalledWith(3); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('deleted') }), + ); + }); + }); + + describe('list', () => { + it('shows "none" message when no menus', async () => { + makePool(); + listMenusForGuild.mockResolvedValue([]); + const interaction = makeInteraction('list'); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('No reaction-role menus') }), + ); + }); + + it('lists menus when some exist', async () => { + makePool(); + listMenusForGuild.mockResolvedValue([ + { id: 1, title: 'Colors', channel_id: 'ch-1', message_id: 'msg-1' }, + { id: 2, title: 'Games', channel_id: 'ch-2', message_id: 'msg-2' }, + ]); + const interaction = makeInteraction('list'); + await execute(interaction); + expect(safeEditReply).toHaveBeenCalledWith( + interaction, + expect.objectContaining({ content: expect.stringContaining('Colors') }), + ); + }); + }); +}); diff --git a/tests/modules/reactionRoles.test.js b/tests/modules/reactionRoles.test.js new file mode 100644 index 00000000..d7ef7330 --- /dev/null +++ b/tests/modules/reactionRoles.test.js @@ -0,0 +1,359 @@ +/** + * Tests for src/modules/reactionRoles.js + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +import { getPool } from '../../src/db.js'; +import { + buildReactionRoleEmbed, + deleteMenu, + findMenuByMessageId, + findRoleForReaction, + getEntriesForMenu, + handleReactionRoleAdd, + handleReactionRoleRemove, + insertReactionRoleMenu, + listMenusForGuild, + removeReactionRoleEntry, + resolveEmojiString, + upsertReactionRoleEntry, +} from '../../src/modules/reactionRoles.js'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function mockPool(queryResult = { rows: [], rowCount: 0 }) { + const pool = { query: vi.fn().mockResolvedValue(queryResult) }; + getPool.mockReturnValue(pool); + return pool; +} + +function makeEmoji(overrides = {}) { + return { id: null, name: '👍', animated: false, ...overrides }; +} + +function makeReaction({ messageId = 'msg-1', guildId = 'guild-1', emoji = makeEmoji() } = {}) { + const guild = { + id: guildId, + members: { fetch: vi.fn() }, + roles: { cache: new Map(), fetch: vi.fn() }, + }; + return { + emoji, + message: { + id: messageId, + guild, + partial: false, + fetch: vi.fn(), + }, + }; +} + +function makeUser(id = 'user-1', bot = false) { + return { id, bot }; +} + +// ── resolveEmojiString ──────────────────────────────────────────────────────── + +describe('resolveEmojiString', () => { + it('returns emoji name for unicode emoji', () => { + expect(resolveEmojiString({ id: null, name: '⭐', animated: false })).toBe('⭐'); + }); + + it('returns <:name:id> for custom emoji', () => { + expect(resolveEmojiString({ id: '123456', name: 'cool', animated: false })).toBe( + '<:cool:123456>', + ); + }); + + it('returns for animated custom emoji', () => { + expect(resolveEmojiString({ id: '789', name: 'wave', animated: true })).toBe(''); + }); +}); + +// ── buildReactionRoleEmbed ──────────────────────────────────────────────────── + +describe('buildReactionRoleEmbed', () => { + it('returns an EmbedBuilder with title and footer', () => { + const embed = buildReactionRoleEmbed('Pick a Role', null, []); + const data = embed.toJSON(); + expect(data.title).toBe('Pick a Role'); + expect(data.footer?.text).toMatch(/React to this message/); + }); + + it('includes placeholder text when no entries provided', () => { + const embed = buildReactionRoleEmbed('Roles', null, []); + const data = embed.toJSON(); + expect(data.description).toContain('No roles configured'); + }); + + it('lists entries in description', () => { + const entries = [ + { emoji: '⭐', role_id: 'role-1' }, + { emoji: '<:cool:123>', role_id: 'role-2' }, + ]; + const embed = buildReactionRoleEmbed('Roles', 'Pick one', entries); + const data = embed.toJSON(); + expect(data.description).toContain('⭐'); + expect(data.description).toContain('role-1'); + expect(data.description).toContain('<:cool:123>'); + expect(data.description).toContain('Pick one'); + }); +}); + +// ── DB helpers ──────────────────────────────────────────────────────────────── + +describe('insertReactionRoleMenu', () => { + it('calls INSERT ... ON CONFLICT and returns the row', async () => { + const row = { + id: 1, + guild_id: 'g-1', + channel_id: 'ch-1', + message_id: 'msg-1', + title: 'T', + description: null, + }; + const pool = mockPool({ rows: [row] }); + const result = await insertReactionRoleMenu('g-1', 'ch-1', 'msg-1', 'T', null); + expect(pool.query).toHaveBeenCalledOnce(); + expect(result).toEqual(row); + }); +}); + +describe('findMenuByMessageId', () => { + it('returns the row when found', async () => { + const row = { id: 1, message_id: 'msg-1' }; + mockPool({ rows: [row] }); + expect(await findMenuByMessageId('msg-1')).toEqual(row); + }); + + it('returns null when not found', async () => { + mockPool({ rows: [] }); + expect(await findMenuByMessageId('ghost')).toBeNull(); + }); +}); + +describe('listMenusForGuild', () => { + it('returns all rows', async () => { + const rows = [{ id: 1 }, { id: 2 }]; + mockPool({ rows }); + expect(await listMenusForGuild('g-1')).toEqual(rows); + }); + + it('returns empty array when none', async () => { + mockPool({ rows: [] }); + expect(await listMenusForGuild('g-1')).toEqual([]); + }); +}); + +describe('deleteMenu', () => { + it('returns true when row deleted', async () => { + mockPool({ rows: [], rowCount: 1 }); + expect(await deleteMenu(1)).toBe(true); + }); + + it('returns false when nothing deleted', async () => { + mockPool({ rows: [], rowCount: 0 }); + expect(await deleteMenu(99)).toBe(false); + }); +}); + +describe('upsertReactionRoleEntry', () => { + it('returns the inserted row', async () => { + const row = { id: 1, menu_id: 1, emoji: '⭐', role_id: 'r-1' }; + const pool = mockPool({ rows: [row] }); + const result = await upsertReactionRoleEntry(1, '⭐', 'r-1'); + expect(pool.query).toHaveBeenCalledOnce(); + expect(result).toEqual(row); + }); +}); + +describe('removeReactionRoleEntry', () => { + it('returns true when deleted', async () => { + mockPool({ rows: [], rowCount: 1 }); + expect(await removeReactionRoleEntry(1, '⭐')).toBe(true); + }); + + it('returns false when not found', async () => { + mockPool({ rows: [], rowCount: 0 }); + expect(await removeReactionRoleEntry(1, '❓')).toBe(false); + }); +}); + +describe('getEntriesForMenu', () => { + it('returns all entries', async () => { + const rows = [{ id: 1, emoji: '⭐', role_id: 'r-1' }]; + mockPool({ rows }); + expect(await getEntriesForMenu(1)).toEqual(rows); + }); +}); + +describe('findRoleForReaction', () => { + it('returns roleId when mapping exists', async () => { + mockPool({ rows: [{ role_id: 'r-42' }] }); + expect(await findRoleForReaction('msg-1', '⭐')).toBe('r-42'); + }); + + it('returns null when no mapping', async () => { + mockPool({ rows: [] }); + expect(await findRoleForReaction('msg-1', '🦄')).toBeNull(); + }); +}); + +// ── handleReactionRoleAdd ───────────────────────────────────────────────────── + +describe('handleReactionRoleAdd', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does nothing when pool is unavailable', async () => { + getPool.mockReturnValue(null); + const reaction = makeReaction(); + // Should not throw + await expect(handleReactionRoleAdd(reaction, makeUser())).resolves.toBeUndefined(); + }); + + it('does nothing when no role mapping found', async () => { + // findRoleForReaction → null + mockPool({ rows: [] }); + const reaction = makeReaction(); + await handleReactionRoleAdd(reaction, makeUser()); + // No member.roles.add should have been called + }); + + it('grants the role when mapping exists and member lacks it', async () => { + const pool = { query: vi.fn() }; + // findRoleForReaction returns 'role-99' + pool.query.mockResolvedValueOnce({ rows: [{ role_id: 'role-99' }] }); + getPool.mockReturnValue(pool); + + const role = { id: 'role-99', position: 1 }; + const member = { + roles: { + cache: new Map(), + add: vi.fn().mockResolvedValue(undefined), + }, + }; + const guild = { + id: 'guild-1', + members: { fetch: vi.fn().mockResolvedValue(member) }, + roles: { cache: new Map([['role-99', role]]), fetch: vi.fn() }, + }; + const reaction = { + emoji: { id: null, name: '⭐', animated: false }, + message: { id: 'msg-1', guild, partial: false, fetch: vi.fn() }, + }; + + await handleReactionRoleAdd(reaction, makeUser()); + expect(member.roles.add).toHaveBeenCalledWith(role, 'Reaction role assignment'); + }); + + it('skips granting if member already has the role', async () => { + const pool = { query: vi.fn() }; + pool.query.mockResolvedValueOnce({ rows: [{ role_id: 'role-99' }] }); + getPool.mockReturnValue(pool); + + const role = { id: 'role-99', position: 1 }; + const member = { + roles: { + cache: new Map([['role-99', role]]), + add: vi.fn(), + }, + }; + const guild = { + id: 'guild-1', + members: { fetch: vi.fn().mockResolvedValue(member) }, + roles: { cache: new Map([['role-99', role]]), fetch: vi.fn() }, + }; + const reaction = { + emoji: { id: null, name: '⭐', animated: false }, + message: { id: 'msg-1', guild, partial: false, fetch: vi.fn() }, + }; + + await handleReactionRoleAdd(reaction, makeUser()); + expect(member.roles.add).not.toHaveBeenCalled(); + }); +}); + +// ── handleReactionRoleRemove ────────────────────────────────────────────────── + +describe('handleReactionRoleRemove', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does nothing when pool is unavailable', async () => { + getPool.mockReturnValue(null); + const reaction = makeReaction(); + await expect(handleReactionRoleRemove(reaction, makeUser())).resolves.toBeUndefined(); + }); + + it('does nothing when no role mapping found', async () => { + mockPool({ rows: [] }); + const reaction = makeReaction(); + await handleReactionRoleRemove(reaction, makeUser()); + // No removal should be attempted + }); + + it('removes the role when member has it', async () => { + const pool = { query: vi.fn() }; + pool.query.mockResolvedValueOnce({ rows: [{ role_id: 'role-77' }] }); + getPool.mockReturnValue(pool); + + const member = { + roles: { + cache: new Map([['role-77', {}]]), + remove: vi.fn().mockResolvedValue(undefined), + }, + }; + const guild = { + id: 'guild-1', + members: { fetch: vi.fn().mockResolvedValue(member) }, + }; + const reaction = { + emoji: { id: null, name: '⭐', animated: false }, + message: { id: 'msg-1', guild, partial: false, fetch: vi.fn() }, + }; + + await handleReactionRoleRemove(reaction, makeUser()); + expect(member.roles.remove).toHaveBeenCalledWith('role-77', 'Reaction role removal'); + }); + + it('skips removal if member does not have the role', async () => { + const pool = { query: vi.fn() }; + pool.query.mockResolvedValueOnce({ rows: [{ role_id: 'role-77' }] }); + getPool.mockReturnValue(pool); + + const member = { + roles: { + cache: new Map(), // member doesn't have role-77 + remove: vi.fn(), + }, + }; + const guild = { + id: 'guild-1', + members: { fetch: vi.fn().mockResolvedValue(member) }, + }; + const reaction = { + emoji: { id: null, name: '⭐', animated: false }, + message: { id: 'msg-1', guild, partial: false, fetch: vi.fn() }, + }; + + await handleReactionRoleRemove(reaction, makeUser()); + expect(member.roles.remove).not.toHaveBeenCalled(); + }); +});