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:
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user