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.
This commit is contained in:
2026-07-16 18:54:06 +12:00
parent 932cfc57d3
commit 0ab2b5c3ef
10 changed files with 955 additions and 2 deletions
@@ -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,
});
}
@@ -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,
});
}
+24 -1
View File
@@ -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 * @param {import('better-sqlite3').Database} db
*/ */
@@ -26,5 +26,28 @@ export function runMigrations(db) {
CREATE INDEX IF NOT EXISTS idx_reaction_roles_message CREATE INDEX IF NOT EXISTS idx_reaction_roles_message
ON reaction_roles (guild_id, message_id); 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);
`); `);
} }
@@ -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),
});
}
},
};
@@ -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),
});
}
},
};
@@ -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;
@@ -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;
+4
View File
@@ -46,6 +46,10 @@ export function describeCommandError(error) {
return 'No reaction-role mapping was found for that message and emoji.'; return 'No reaction-role mapping was found for that message and emoji.';
case 'NO_MAPPINGS_FOR_MESSAGE': case 'NO_MAPPINGS_FOR_MESSAGE':
return 'No reaction-role mappings were found for that 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: default:
break; break;
} }
+1 -1
View File
@@ -69,7 +69,7 @@ export function canBotManageRole(guild, role) {
if (role.id === guild.id) { if (role.id === guild.id) {
return { return {
ok: false, ok: false,
reason: 'The @everyone role cannot be used for reaction roles.', reason: 'The @everyone role cannot be used here.',
}; };
} }
@@ -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<string>} addedRoleIds
* @returns {Promise<void>}
*/
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),
});
}
}
}