From 0ab2b5c3ef1bdb73d558a775ed12db5495345a94 Mon Sep 17 00:00:00 2001 From: Andrew Zambazos Date: Thu, 16 Jul 2026 18:54:06 +1200 Subject: [PATCH] Add auto-role and role replacement rules Adds slash commands, services, migrations, and member event handlers for configuring per-guild auto-roles and role replacement rules. Also updates shared error/permission messaging to support the new role management flows. --- .../src/commands/autoRoles/autoRole.js | 176 +++++++++++++ .../commands/roleReplacements/roleReplace.js | 248 ++++++++++++++++++ Everything-Bot/src/database/migrations.js | 25 +- Everything-Bot/src/events/guildMemberAdd.js | 95 +++++++ .../src/events/guildMemberUpdate.js | 41 +++ .../src/services/autoRoleService.js | 99 +++++++ .../src/services/roleReplacementService.js | 180 +++++++++++++ Everything-Bot/src/utilities/errors.js | 4 + Everything-Bot/src/utilities/permissions.js | 2 +- .../src/utilities/roleReplacements.js | 87 ++++++ 10 files changed, 955 insertions(+), 2 deletions(-) create mode 100644 Everything-Bot/src/commands/autoRoles/autoRole.js create mode 100644 Everything-Bot/src/commands/roleReplacements/roleReplace.js create mode 100644 Everything-Bot/src/events/guildMemberAdd.js create mode 100644 Everything-Bot/src/events/guildMemberUpdate.js create mode 100644 Everything-Bot/src/services/autoRoleService.js create mode 100644 Everything-Bot/src/services/roleReplacementService.js create mode 100644 Everything-Bot/src/utilities/roleReplacements.js diff --git a/Everything-Bot/src/commands/autoRoles/autoRole.js b/Everything-Bot/src/commands/autoRoles/autoRole.js new file mode 100644 index 0000000..649c60b --- /dev/null +++ b/Everything-Bot/src/commands/autoRoles/autoRole.js @@ -0,0 +1,176 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + MessageFlags, +} from 'discord.js'; +import { requireManageRoles, canBotManageRole } from '../../utilities/permissions.js'; +import { resolveInteractionGuild } from '../../utilities/channels.js'; +import { autoRoleService } from '../../services/autoRoleService.js'; +import { + replyEphemeralError, + describeCommandError, + isDatabaseError, +} from '../../utilities/errors.js'; + +export default { + data: new SlashCommandBuilder() + .setName('autorole') + .setDescription('Configure the role automatically assigned to new members') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) + .setDMPermission(false) + .addSubcommand((subcommand) => + subcommand + .setName('set') + .setDescription('Set the role assigned when someone joins') + .addRoleOption((option) => + option + .setName('role') + .setDescription('Role to assign to new members') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand.setName('clear').setDescription('Stop assigning a role to new members'), + ) + .addSubcommand((subcommand) => + subcommand.setName('show').setDescription('Show the current auto-role'), + ), + + /** + * @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 subcommand = interaction.options.getSubcommand(true); + + if (subcommand === 'show') { + await handleShow(interaction, guild); + return; + } + + if (subcommand === 'clear') { + await handleClear(interaction, guild); + return; + } + + if (subcommand === 'set') { + await handleSet(interaction, guild); + } + }, +}; + +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {import('discord.js').Guild} guild + */ +async function handleShow(interaction, guild) { + let config; + try { + config = autoRoleService.get(guild.id); + } catch (error) { + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + if (!config) { + await interaction.reply({ + content: 'No auto-role is configured. New members will not receive a role automatically.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.reply({ + content: `New members are automatically assigned <@&${config.role_id}>.`, + flags: MessageFlags.Ephemeral, + }); +} + +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {import('discord.js').Guild} guild + */ +async function handleClear(interaction, guild) { + let cleared; + try { + cleared = autoRoleService.clear(guild.id); + } catch (error) { + if (isDatabaseError(error)) { + await replyEphemeralError(interaction, 'A database error occurred while clearing the auto-role.'); + return; + } + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + if (!cleared) { + await interaction.reply({ + content: 'No auto-role was configured.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.reply({ + content: `Auto-role cleared. New members will no longer receive <@&${cleared.role_id}> automatically.`, + flags: MessageFlags.Ephemeral, + }); +} + +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {import('discord.js').Guild} guild + */ +async function handleSet(interaction, guild) { + const role = interaction.options.getRole('role', true); + + 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; + } + + try { + autoRoleService.set({ + guildId: guild.id, + roleId: guildRole.id, + updatedBy: interaction.user.id, + }); + } catch (error) { + if (isDatabaseError(error)) { + await replyEphemeralError(interaction, 'A database error occurred while saving the auto-role.'); + return; + } + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + await interaction.reply({ + content: + `Auto-role set to ${guildRole}.\n` + + `New members will receive this role when they join.`, + flags: MessageFlags.Ephemeral, + }); +} diff --git a/Everything-Bot/src/commands/roleReplacements/roleReplace.js b/Everything-Bot/src/commands/roleReplacements/roleReplace.js new file mode 100644 index 0000000..e939c95 --- /dev/null +++ b/Everything-Bot/src/commands/roleReplacements/roleReplace.js @@ -0,0 +1,248 @@ +import { + SlashCommandBuilder, + PermissionFlagsBits, + EmbedBuilder, + MessageFlags, +} from 'discord.js'; +import { requireManageRoles, canBotManageRole } from '../../utilities/permissions.js'; +import { resolveInteractionGuild } from '../../utilities/channels.js'; +import { roleReplacementService } from '../../services/roleReplacementService.js'; +import { + replyEphemeralError, + describeCommandError, + isDatabaseError, +} from '../../utilities/errors.js'; + +export default { + data: new SlashCommandBuilder() + .setName('role-replace') + .setDescription('When one role is assigned, automatically remove another') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageRoles) + .setDMPermission(false) + .addSubcommand((subcommand) => + subcommand + .setName('add') + .setDescription('When assign_role is given, remove remove_role') + .addRoleOption((option) => + option + .setName('assign_role') + .setDescription('Role that triggers the removal') + .setRequired(true), + ) + .addRoleOption((option) => + option + .setName('remove_role') + .setDescription('Role to remove when assign_role is assigned') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Delete a role replacement rule') + .addRoleOption((option) => + option + .setName('assign_role') + .setDescription('Role that triggers the removal') + .setRequired(true), + ) + .addRoleOption((option) => + option + .setName('remove_role') + .setDescription('Role that was being removed') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand.setName('list').setDescription('List role replacement rules in this server'), + ), + + /** + * @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 subcommand = interaction.options.getSubcommand(true); + + if (subcommand === 'list') { + await handleList(interaction, guild); + return; + } + + if (subcommand === 'remove') { + await handleRemove(interaction, guild); + return; + } + + if (subcommand === 'add') { + await handleAdd(interaction, guild); + } + }, +}; + +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {import('discord.js').Guild} guild + */ +async function handleList(interaction, guild) { + let rules; + try { + rules = roleReplacementService.listByGuild(guild.id); + } catch (error) { + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + if (rules.length === 0) { + await interaction.reply({ + content: 'No role replacement rules are configured in this server.', + flags: MessageFlags.Ephemeral, + }); + return; + } + + const lines = rules.map((row, index) => { + return `**${index + 1}.** <@&${row.assign_role_id}> assigned → remove <@&${row.remove_role_id}>`; + }); + + const embed = new EmbedBuilder() + .setTitle('Role replacements') + .setDescription(lines.join('\n')) + .setColor(0x5865f2) + .setFooter({ text: `${rules.length} rule${rules.length === 1 ? '' : 's'}` }) + .setTimestamp(); + + await interaction.reply({ + embeds: [embed], + flags: MessageFlags.Ephemeral, + }); +} + +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {import('discord.js').Guild} guild + */ +async function handleRemove(interaction, guild) { + const assignRole = interaction.options.getRole('assign_role', true); + const removeRole = interaction.options.getRole('remove_role', true); + + let deleted; + try { + deleted = roleReplacementService.remove(guild.id, assignRole.id, removeRole.id); + } catch (error) { + if (isDatabaseError(error)) { + await replyEphemeralError( + interaction, + 'A database error occurred while removing the role replacement.', + ); + return; + } + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + if (!deleted) { + await replyEphemeralError( + interaction, + describeCommandError(new Error('REPLACEMENT_NOT_FOUND')), + ); + return; + } + + await interaction.reply({ + content: + `Role replacement removed.\n` + + `Assigning ${assignRole} will no longer remove ${removeRole}.`, + flags: MessageFlags.Ephemeral, + }); +} + +/** + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {import('discord.js').Guild} guild + */ +async function handleAdd(interaction, guild) { + const assignRoleOption = interaction.options.getRole('assign_role', true); + const removeRoleOption = interaction.options.getRole('remove_role', true); + + if (assignRoleOption.id === removeRoleOption.id) { + await replyEphemeralError( + interaction, + 'assign_role and remove_role must be different roles.', + ); + return; + } + + /** @type {import('discord.js').Role | null} */ + const assignRole = + 'position' in assignRoleOption && typeof assignRoleOption.position === 'number' + ? assignRoleOption + : await guild.roles.fetch(assignRoleOption.id).catch(() => null); + + /** @type {import('discord.js').Role | null} */ + const removeRole = + 'position' in removeRoleOption && typeof removeRoleOption.position === 'number' + ? removeRoleOption + : await guild.roles.fetch(removeRoleOption.id).catch(() => null); + + if (!assignRole || !removeRole) { + await replyEphemeralError(interaction, describeCommandError(new Error('ROLE_NOT_FOUND'))); + return; + } + + const assignCheck = canBotManageRole(guild, assignRole); + if (!assignCheck.ok) { + await replyEphemeralError( + interaction, + `Cannot use assign_role: ${assignCheck.reason}`, + ); + return; + } + + const removeCheck = canBotManageRole(guild, removeRole); + if (!removeCheck.ok) { + await replyEphemeralError( + interaction, + `Cannot use remove_role: ${removeCheck.reason}`, + ); + return; + } + + try { + roleReplacementService.create({ + guildId: guild.id, + assignRoleId: assignRole.id, + removeRoleId: removeRole.id, + createdBy: interaction.user.id, + }); + } catch (error) { + if (isDatabaseError(error) && error.message !== 'DUPLICATE_REPLACEMENT') { + await replyEphemeralError( + interaction, + 'A database error occurred while saving the role replacement.', + ); + return; + } + await replyEphemeralError(interaction, describeCommandError(error)); + return; + } + + await interaction.reply({ + content: + `Role replacement created.\n` + + `When ${assignRole} is assigned, ${removeRole} will be removed.`, + flags: MessageFlags.Ephemeral, + }); +} diff --git a/Everything-Bot/src/database/migrations.js b/Everything-Bot/src/database/migrations.js index aea68f2..521e8a9 100644 --- a/Everything-Bot/src/database/migrations.js +++ b/Everything-Bot/src/database/migrations.js @@ -1,5 +1,5 @@ /** - * Creates the reaction_roles table and indexes if they do not already exist. + * Creates tables and indexes if they do not already exist. * * @param {import('better-sqlite3').Database} db */ @@ -26,5 +26,28 @@ export function runMigrations(db) { CREATE INDEX IF NOT EXISTS idx_reaction_roles_message ON reaction_roles (guild_id, message_id); + + CREATE TABLE IF NOT EXISTS guild_autoroles ( + guild_id TEXT PRIMARY KEY, + role_id TEXT NOT NULL, + updated_by TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS role_replacements ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + assign_role_id TEXT NOT NULL, + remove_role_id TEXT NOT NULL, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (guild_id, assign_role_id, remove_role_id) + ); + + CREATE INDEX IF NOT EXISTS idx_role_replacements_lookup + ON role_replacements (guild_id, assign_role_id); + + CREATE INDEX IF NOT EXISTS idx_role_replacements_guild + ON role_replacements (guild_id); `); } diff --git a/Everything-Bot/src/events/guildMemberAdd.js b/Everything-Bot/src/events/guildMemberAdd.js new file mode 100644 index 0000000..acf5552 --- /dev/null +++ b/Everything-Bot/src/events/guildMemberAdd.js @@ -0,0 +1,95 @@ +import { Events } from 'discord.js'; +import { autoRoleService } from '../services/autoRoleService.js'; +import { canBotManageRole } from '../utilities/permissions.js'; +import logger from '../utilities/logger.js'; + +export default { + name: Events.GuildMemberAdd, + once: false, + + /** + * @param {import('discord.js').GuildMember | import('discord.js').PartialGuildMember} member + */ + async execute(member) { + if (member.partial) { + try { + member = await member.fetch(); + } catch (error) { + logger.warn('Failed to fetch partial member on join', { + error: error instanceof Error ? error.message : String(error), + }); + return; + } + } + + if (member.user.bot) { + return; + } + + let config; + try { + config = autoRoleService.get(member.guild.id); + } catch (error) { + logger.error('Failed to load auto-role config on join', { + guildId: member.guild.id, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + if (!config) { + return; + } + + let role; + try { + role = await member.guild.roles.fetch(config.role_id); + } catch (error) { + logger.warn('Auto-role fetch failed on join', { + roleId: config.role_id, + guildId: member.guild.id, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + if (!role) { + logger.warn('Configured auto-role no longer exists', { + roleId: config.role_id, + guildId: member.guild.id, + }); + return; + } + + const permissionCheck = canBotManageRole(member.guild, role); + if (!permissionCheck.ok) { + logger.warn('Permission failure while assigning auto-role', { + reason: permissionCheck.reason, + roleId: role.id, + guildId: member.guild.id, + userId: member.id, + }); + return; + } + + if (member.roles.cache.has(role.id)) { + return; + } + + try { + await member.roles.add(role, 'Auto-role on join'); + logger.info('Auto-role assigned on join', { + userId: member.id, + roleId: role.id, + guildId: member.guild.id, + }); + } catch (error) { + logger.error('Discord API error while assigning auto-role', { + userId: member.id, + roleId: role.id, + guildId: member.guild.id, + error: error instanceof Error ? error.message : String(error), + }); + } + }, +}; diff --git a/Everything-Bot/src/events/guildMemberUpdate.js b/Everything-Bot/src/events/guildMemberUpdate.js new file mode 100644 index 0000000..44a9fd5 --- /dev/null +++ b/Everything-Bot/src/events/guildMemberUpdate.js @@ -0,0 +1,41 @@ +import { Events } from 'discord.js'; +import { enforceRoleReplacements } from '../utilities/roleReplacements.js'; +import logger from '../utilities/logger.js'; + +export default { + name: Events.GuildMemberUpdate, + once: false, + + /** + * @param {import('discord.js').GuildMember | import('discord.js').PartialGuildMember} oldMember + * @param {import('discord.js').GuildMember} newMember + */ + async execute(oldMember, newMember) { + if (newMember.user.bot) { + return; + } + + try { + if (oldMember.partial) { + // Without the previous role set we cannot detect which roles were added + return; + } + + const addedRoleIds = newMember.roles.cache + .filter((role) => !oldMember.roles.cache.has(role.id)) + .map((role) => role.id); + + if (addedRoleIds.length === 0) { + return; + } + + await enforceRoleReplacements(newMember, addedRoleIds); + } catch (error) { + logger.error('Unexpected error while handling role replacements', { + guildId: newMember.guild.id, + userId: newMember.id, + error: error instanceof Error ? error.message : String(error), + }); + } + }, +}; diff --git a/Everything-Bot/src/services/autoRoleService.js b/Everything-Bot/src/services/autoRoleService.js new file mode 100644 index 0000000..5af9473 --- /dev/null +++ b/Everything-Bot/src/services/autoRoleService.js @@ -0,0 +1,99 @@ +import { getDatabase } from '../database/database.js'; +import logger from '../utilities/logger.js'; + +/** + * @typedef {object} AutoRoleRow + * @property {string} guild_id + * @property {string} role_id + * @property {string} updated_by + * @property {string} updated_at + */ + +/** + * Data-access helpers for per-guild auto-roles assigned on join. + */ +export const autoRoleService = { + /** + * @param {string} guildId + * @returns {AutoRoleRow | undefined} + */ + get(guildId) { + return getDatabase() + .prepare('SELECT * FROM guild_autoroles WHERE guild_id = ?') + .get(guildId); + }, + + /** + * Creates or updates the auto-role for a guild. + * @param {{ guildId: string, roleId: string, updatedBy: string }} input + * @returns {AutoRoleRow} + */ + set(input) { + const db = getDatabase(); + + try { + db.prepare( + `INSERT INTO guild_autoroles (guild_id, role_id, updated_by, updated_at) + VALUES (@guildId, @roleId, @updatedBy, datetime('now')) + ON CONFLICT(guild_id) DO UPDATE SET + role_id = excluded.role_id, + updated_by = excluded.updated_by, + updated_at = datetime('now')`, + ).run({ + guildId: input.guildId, + roleId: input.roleId, + updatedBy: input.updatedBy, + }); + + const row = this.get(input.guildId); + if (!row) { + throw new Error('Failed to load auto-role after save.'); + } + + logger.info('Auto-role configured', { + guildId: row.guild_id, + roleId: row.role_id, + updatedBy: row.updated_by, + }); + + return row; + } catch (error) { + logger.error('Database error while saving auto-role', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, + + /** + * Clears the auto-role for a guild. + * @param {string} guildId + * @returns {AutoRoleRow | undefined} The previous row, if any + */ + clear(guildId) { + const existing = this.get(guildId); + if (!existing) { + return undefined; + } + + try { + getDatabase() + .prepare('DELETE FROM guild_autoroles WHERE guild_id = ?') + .run(guildId); + + logger.info('Auto-role cleared', { + guildId, + roleId: existing.role_id, + }); + + return existing; + } catch (error) { + logger.error('Database error while clearing auto-role', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, +}; + +export default autoRoleService; diff --git a/Everything-Bot/src/services/roleReplacementService.js b/Everything-Bot/src/services/roleReplacementService.js new file mode 100644 index 0000000..92ad36c --- /dev/null +++ b/Everything-Bot/src/services/roleReplacementService.js @@ -0,0 +1,180 @@ +import { getDatabase } from '../database/database.js'; +import logger from '../utilities/logger.js'; + +/** + * @typedef {object} RoleReplacementRow + * @property {number} id + * @property {string} guild_id + * @property {string} assign_role_id + * @property {string} remove_role_id + * @property {string} created_by + * @property {string} created_at + */ + +/** + * Data-access helpers for "when role A is assigned, remove role B" rules. + */ +export const roleReplacementService = { + /** + * @param {{ guildId: string, assignRoleId: string, removeRoleId: string, createdBy: string }} input + * @returns {RoleReplacementRow} + */ + create(input) { + const db = getDatabase(); + + try { + const result = db + .prepare( + `INSERT INTO role_replacements ( + guild_id, assign_role_id, remove_role_id, created_by + ) VALUES ( + @guildId, @assignRoleId, @removeRoleId, @createdBy + )`, + ) + .run({ + guildId: input.guildId, + assignRoleId: input.assignRoleId, + removeRoleId: input.removeRoleId, + createdBy: input.createdBy, + }); + + const row = this.getById(Number(result.lastInsertRowid)); + if (!row) { + throw new Error('Failed to load role replacement after insert.'); + } + + logger.info('Role replacement created', { + id: row.id, + guildId: row.guild_id, + assignRoleId: row.assign_role_id, + removeRoleId: row.remove_role_id, + }); + + return row; + } catch (error) { + if (isUniqueConstraintError(error)) { + const duplicateError = new Error('DUPLICATE_REPLACEMENT'); + duplicateError.cause = error; + throw duplicateError; + } + + logger.error('Database error while creating role replacement', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, + + /** + * @param {number} id + * @returns {RoleReplacementRow | undefined} + */ + getById(id) { + return getDatabase() + .prepare('SELECT * FROM role_replacements WHERE id = ?') + .get(id); + }, + + /** + * Roles that should be removed when any of the given roles are assigned. + * @param {string} guildId + * @param {string[]} assignRoleIds + * @returns {string[]} Unique role IDs to remove + */ + getRemoveRoleIds(guildId, assignRoleIds) { + if (assignRoleIds.length === 0) { + return []; + } + + const placeholders = assignRoleIds.map(() => '?').join(', '); + /** @type {{ remove_role_id: string }[]} */ + const rows = getDatabase() + .prepare( + `SELECT DISTINCT remove_role_id + FROM role_replacements + WHERE guild_id = ? AND assign_role_id IN (${placeholders})`, + ) + .all(guildId, ...assignRoleIds); + + return rows.map((row) => row.remove_role_id); + }, + + /** + * @param {string} guildId + * @returns {RoleReplacementRow[]} + */ + listByGuild(guildId) { + return getDatabase() + .prepare( + `SELECT * FROM role_replacements + WHERE guild_id = ? + ORDER BY created_at DESC, id DESC`, + ) + .all(guildId); + }, + + /** + * @param {string} guildId + * @param {string} assignRoleId + * @param {string} removeRoleId + * @returns {RoleReplacementRow | undefined} + */ + find(guildId, assignRoleId, removeRoleId) { + return getDatabase() + .prepare( + `SELECT * FROM role_replacements + WHERE guild_id = ? AND assign_role_id = ? AND remove_role_id = ?`, + ) + .get(guildId, assignRoleId, removeRoleId); + }, + + /** + * @param {string} guildId + * @param {string} assignRoleId + * @param {string} removeRoleId + * @returns {RoleReplacementRow | undefined} The deleted row, if any + */ + remove(guildId, assignRoleId, removeRoleId) { + const existing = this.find(guildId, assignRoleId, removeRoleId); + if (!existing) { + return undefined; + } + + try { + getDatabase() + .prepare( + `DELETE FROM role_replacements + WHERE guild_id = ? AND assign_role_id = ? AND remove_role_id = ?`, + ) + .run(guildId, assignRoleId, removeRoleId); + + logger.info('Role replacement removed', { + id: existing.id, + guildId, + assignRoleId, + removeRoleId, + }); + + return existing; + } catch (error) { + logger.error('Database error while removing role replacement', { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + }, +}; + +/** + * @param {unknown} error + * @returns {boolean} + */ +function isUniqueConstraintError(error) { + return ( + error instanceof Error && + typeof error.message === 'string' && + error.message.includes('UNIQUE constraint failed') + ); +} + +export default roleReplacementService; diff --git a/Everything-Bot/src/utilities/errors.js b/Everything-Bot/src/utilities/errors.js index 191753b..9e26c01 100644 --- a/Everything-Bot/src/utilities/errors.js +++ b/Everything-Bot/src/utilities/errors.js @@ -46,6 +46,10 @@ export function describeCommandError(error) { return 'No reaction-role mapping was found for that message and emoji.'; case 'NO_MAPPINGS_FOR_MESSAGE': return 'No reaction-role mappings were found for that message.'; + case 'DUPLICATE_REPLACEMENT': + return 'That role replacement rule already exists.'; + case 'REPLACEMENT_NOT_FOUND': + return 'No role replacement rule was found for those roles.'; default: break; } diff --git a/Everything-Bot/src/utilities/permissions.js b/Everything-Bot/src/utilities/permissions.js index 18516c6..3c7c290 100644 --- a/Everything-Bot/src/utilities/permissions.js +++ b/Everything-Bot/src/utilities/permissions.js @@ -69,7 +69,7 @@ export function canBotManageRole(guild, role) { if (role.id === guild.id) { return { ok: false, - reason: 'The @everyone role cannot be used for reaction roles.', + reason: 'The @everyone role cannot be used here.', }; } diff --git a/Everything-Bot/src/utilities/roleReplacements.js b/Everything-Bot/src/utilities/roleReplacements.js new file mode 100644 index 0000000..159863c --- /dev/null +++ b/Everything-Bot/src/utilities/roleReplacements.js @@ -0,0 +1,87 @@ +import { canBotManageRole } from './permissions.js'; +import { roleReplacementService } from '../services/roleReplacementService.js'; +import logger from './logger.js'; + +/** + * When one or more roles were just assigned, remove any configured replacement targets. + * + * @param {import('discord.js').GuildMember} member + * @param {Iterable} addedRoleIds + * @returns {Promise} + */ +export async function enforceRoleReplacements(member, addedRoleIds) { + const added = [...new Set(addedRoleIds)].filter((roleId) => roleId !== member.guild.id); + if (added.length === 0) { + return; + } + + let removeRoleIds; + try { + removeRoleIds = roleReplacementService.getRemoveRoleIds(member.guild.id, added); + } catch (error) { + logger.error('Failed to look up role replacements', { + guildId: member.guild.id, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + + // Never remove a role that was just assigned in the same update + const toRemove = removeRoleIds.filter( + (roleId) => !added.includes(roleId) && member.roles.cache.has(roleId), + ); + + if (toRemove.length === 0) { + return; + } + + for (const roleId of toRemove) { + let role; + try { + role = await member.guild.roles.fetch(roleId); + } catch (error) { + logger.warn('Role fetch failed while enforcing role replacement', { + roleId, + guildId: member.guild.id, + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + + if (!role) { + logger.warn('Configured replacement role no longer exists', { + roleId, + guildId: member.guild.id, + }); + continue; + } + + const permissionCheck = canBotManageRole(member.guild, role); + if (!permissionCheck.ok) { + logger.warn('Permission failure while enforcing role replacement', { + reason: permissionCheck.reason, + roleId: role.id, + guildId: member.guild.id, + userId: member.id, + }); + continue; + } + + try { + await member.roles.remove(role, 'Role replacement'); + logger.info('Role removed via replacement rule', { + userId: member.id, + removedRoleId: role.id, + triggerRoleIds: added, + guildId: member.guild.id, + }); + } catch (error) { + logger.error('Discord API error while removing replaced role', { + userId: member.id, + roleId: role.id, + guildId: member.guild.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } +}