Files
Discord_Bots/Everything-Bot/src/commands/reactionRoles/createReactionRole.js
T
andrew 932cfc57d3 Add Everything-Bot Discord bot with reaction roles
Initial setup of a modular Discord bot featuring reaction-role management. Includes slash commands for creating, listing, removing, and clearing reaction roles with support for unicode and custom emojis. Features persistent SQLite storage, automatic command and event loading, graceful shutdown handling, and structured logging. Designed with extensibility in mind for future commands and events.
2026-07-16 15:38:43 +12:00

181 lines
5.7 KiB
JavaScript

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,
});
},
};