import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags, } from 'discord.js'; import { parseEmoji, isCustomEmojiAvailable } from '../../utilities/emoji.js'; import { requireManageRoles, canBotManageRole } from '../../utilities/permissions.js'; import { ensureBotReaction } from '../../utilities/ensureBotReaction.js'; import { resolveInteractionGuild, resolveInteractionTextChannel, } from '../../utilities/channels.js'; import { reactionRoleService } from '../../services/reactionRoleService.js'; import { replyEphemeralError, describeCommandError, isDatabaseError, } from '../../utilities/errors.js'; import logger from '../../utilities/logger.js'; export default { data: new SlashCommandBuilder() .setName('reaction-role') .setDescription('Create a reaction role on a message in this channel') .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) .setDMPermission(false) .addStringOption((option) => option .setName('message_id') .setDescription('ID of the message in this channel') .setRequired(true), ) .addRoleOption((option) => option .setName('role') .setDescription('Role to assign when the reaction is added') .setRequired(true), ) .addStringOption((option) => option .setName('emoji') .setDescription('Unicode emoji or custom emoji markup') .setRequired(true), ), /** * @param {import('discord.js').ChatInputCommandInteraction} interaction */ async execute(interaction) { if (!(await requireManageRoles(interaction))) { return; } const guild = await resolveInteractionGuild(interaction); if (!guild) { await replyEphemeralError( interaction, 'Could not access this server. Make sure the bot is online and has been invited here.', ); return; } const channel = await resolveInteractionTextChannel(interaction); if (!channel) { await replyEphemeralError( interaction, 'This command can only be used in a text channel. Make sure the bot can view this channel.', ); return; } const messageId = interaction.options.getString('message_id', true).trim(); const role = interaction.options.getRole('role', true); const emojiInput = interaction.options.getString('emoji', true); if (!/^\d{17,20}$/.test(messageId)) { await replyEphemeralError( interaction, 'Invalid message ID. Right-click the message → Copy Message ID (Developer Mode must be enabled).', ); return; } const parsedEmoji = parseEmoji(emojiInput); if (!parsedEmoji) { await replyEphemeralError(interaction, describeCommandError(new Error('INVALID_EMOJI'))); return; } if (parsedEmoji.type === 'custom' && !isCustomEmojiAvailable(interaction.client, parsedEmoji.id)) { await replyEphemeralError( interaction, describeCommandError(new Error('CUSTOM_EMOJI_UNAVAILABLE')), ); return; } // Role option may return APIRole; resolve the full Role when possible const guildRole = 'position' in role && typeof role.position === 'number' ? role : await guild.roles.fetch(role.id).catch(() => null); if (!guildRole) { await replyEphemeralError(interaction, describeCommandError(new Error('ROLE_NOT_FOUND'))); return; } const permissionCheck = canBotManageRole(guild, guildRole); if (!permissionCheck.ok) { await replyEphemeralError(interaction, permissionCheck.reason); return; } let message; try { message = await channel.messages.fetch(messageId); } catch (error) { logger.warn('Failed to fetch target message for reaction role', { messageId, channelId: channel.id, error: error instanceof Error ? error.message : String(error), }); await replyEphemeralError(interaction, describeCommandError(new Error('MESSAGE_NOT_FOUND'))); return; } try { // Add the emoji if it is missing (or if the bot has not reacted yet) await ensureBotReaction(message, parsedEmoji.identifier, parsedEmoji.reactValue); } catch (error) { await replyEphemeralError(interaction, describeCommandError(error)); return; } try { reactionRoleService.create({ guildId: guild.id, channelId: channel.id, messageId: message.id, roleId: guildRole.id, emojiIdentifier: parsedEmoji.identifier, emojiDisplay: parsedEmoji.display, createdBy: interaction.user.id, }); } catch (error) { // Best-effort cleanup of the bot reaction if persistence fails try { const botReaction = message.reactions.cache.find((reaction) => { const id = reaction.emoji.id ?? reaction.emoji.name; return id === parsedEmoji.identifier; }); if (botReaction) { await botReaction.users.remove(interaction.client.user.id); } } catch (cleanupError) { logger.warn('Failed to clean up reaction after DB error', { error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError), }); } if (isDatabaseError(error) && error.message !== 'DUPLICATE_MAPPING') { await replyEphemeralError(interaction, 'A database error occurred while saving the reaction role.'); return; } await replyEphemeralError(interaction, describeCommandError(error)); return; } await interaction.reply({ content: `Reaction role created.\n` + `• Message: [${message.id}](${message.url})\n` + `• Role: ${guildRole}\n` + `• Emoji: ${parsedEmoji.display}`, flags: MessageFlags.Ephemeral, }); }, };