import { CacheType, ChatInputCommandInteraction, Client, EmbedBuilder, GatewayIntentBits, Guild, Interaction, REST, Routes, SlashCommandBooleanOption, SlashCommandBuilder, SlashCommandRoleOption, SlashCommandStringOption, SlashCommandSubcommandBuilder, } from "npm:discord.js@14.14.1"; import { RuleSet } from "./rules.ts"; import { channel, log_from, SimpleResult, split_promise, try_ } from "./utils.ts"; const log = log_from(import.meta.url); /** * Wraps a discord bot and implements required actions. */ export class EpitlsBot { private bot; private token; private rest; private rules; private assoc_channel; public constructor(bot_token: string, rules: RuleSet) { this.token = bot_token; const intents = [GatewayIntentBits.Guilds]; this.bot = new Client({ intents }); this.bot.on("interactionCreate", (interaction) => this.handle_interaction(interaction)); this.rest = new REST().setToken(bot_token); this.rules = rules; this.assoc_channel = channel< { cri_login: string; discord_user_id: string; callback: (success: SimpleResult) => void } >(); } /** * Connects to discord API server and registers slash commands.\ * Needs to be run after construction as it is an asynchronous operation. */ public async start() { const { promise, resolver } = split_promise(); this.bot.on("ready", () => resolver()); this.bot.login(this.token); await promise; await this.register_commands(); } /** * Assigns a discord role to a discord user. */ public async assign_role(user_id: string, guild_id: string, role_id: string) { const guild = await this.bot.guilds.fetch(guild_id); const member = await this.try_fetch_member(guild, user_id); if (member === undefined) return; const role = await guild.roles.fetch(role_id); if (role === null) return console.error("Role", role_id, "not found in guild", guild_id); log(`Assigning to user '${member.displayName}' role '${role.name}'.`); await try_( () => member.roles.add(role_id), () => log(`FAILED Assignation to user '${member.displayName}' role '${role.name}'.`), ); } public async update_user_name(user_id: string, must_contain: string, default_suffix: string) { for (const guild of this.bot.guilds.cache.values()) { const user = await this.try_fetch_member(guild, user_id); if (user === undefined) continue; const name = user.displayName; if (to_comparable(name).includes(to_comparable(must_contain))) continue; const DISCORD_MAX_NAME_LENGTH = 35; const generated_name_prefix = name.slice(0, DISCORD_MAX_NAME_LENGTH - default_suffix.length); const generated_name = generated_name_prefix + default_suffix; log(`Setting name of '${name}' to '${generated_name}'.`); await try_(() => user.setNickname(generated_name), () => log(`FAILED Setting name of '${name}'.`)); } } private async try_fetch_member(guild: Guild, user_id: string) { try { return await guild.members.fetch({ user: user_id }); } catch (_) { return undefined; } } /** * Pull-style API to wait for and receive account association requests. */ public async *receive_associations() { while (true) yield await this.assoc_channel.receive(); } /** * Connected bot name accessor. */ public bot_name() { return this.bot.user?.displayName; } public async get_username(user_id: string) { try { const user = await this.bot.users.fetch(user_id); return user.displayName; } catch (_) { return undefined; } } private async register_commands() { const assoc_cmd = new SlashCommandBuilder() .setName("associate") .setDescription("Associates a cri account to your discord account.") .addStringOption( new SlashCommandStringOption() .setName("cri_email") .setDescription("par exemple, claude.nougaro@epita.fr") .setRequired(true), ).toJSON(); const rule_cmd = new SlashCommandBuilder() .setName("rule") .setDescription("Gestion des règles d'associations groupes cri / rôles.") .setDefaultMemberPermissions(0x0000000000000008n) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("list") .setDescription("Liste les règles d'association."), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("add") .setDescription("Ajoute une règle d'association.") .addStringOption( new SlashCommandStringOption() .setName("group") .setDescription("Groupe CRI à associer.") .setRequired(true), ) .addRoleOption( new SlashCommandRoleOption() .setName("role") .setDescription("Role à assigner.") .setRequired(true), ) .addBooleanOption( new SlashCommandBooleanOption() .setName("incl_historical") .setDescription("Assigner le rôle aux membres qui ont été membre du groupe, mais qui ne le sont plus.") .setRequired(true), ), ) .addSubcommand( new SlashCommandSubcommandBuilder() .setName("remove") .setDescription("Retire une règle d'association.") .addStringOption( new SlashCommandStringOption() .setName("group") .setDescription("Groupe CRI à retirer.") .setRequired(true), ), ) .toJSON(); for (const guild_id of this.bot.guilds.cache.keys()) { await this.rest.put( Routes.applicationGuildCommands(this.bot.application!.id, guild_id), { body: [assoc_cmd, rule_cmd] }, ); } } private async handle_interaction(interaction: Interaction) { if (!interaction.isChatInputCommand()) return; if (interaction.commandName === "associate") await this.handle_command_associate(interaction); if (interaction.commandName === "rule") { const subcommand = interaction.options.getSubcommand(); if (subcommand === "list") await this.handle_command_rule_list(interaction); if (subcommand === "add") await this.handle_command_rule_add(interaction); if (subcommand === "remove") await this.handle_command_rule_remove(interaction); } } private async handle_command_associate(interaction: ChatInputCommandInteraction) { const email = interaction.options.getString("cri_email")!; if (!email.endsWith("@epita.fr")) { await interaction.reply(message_command_response_assoc_error("invalid cri email " + email)); return; } const [cri_login] = email.split("@epita.fr"); const discord_user_id = interaction.user.id; const { promise, resolver } = split_promise(); this.assoc_channel.send({ cri_login, discord_user_id, callback: resolver }); await interaction.reply(message_command_response_assoc_pending(email)); log(`Started verification for discord id '${discord_user_id}' with cri login '${cri_login}'.`); const result = await promise; if (result === true) interaction.editReply(message_command_response_assoc_success()); else interaction.editReply(message_command_response_assoc_error(result)); } private async handle_command_rule_list(interaction: ChatInputCommandInteraction) { const guild_id = interaction.guildId; if (guild_id === null) { await interaction.reply( message_command_response_list_error("Cette commande peut être exécutée uniquement sur un serveur."), ); return; } const rules = [] as GuildRule[]; for (const { group_id, role_id, include_historical } of this.rules.roles_for_guild(guild_id)) { const role = await interaction.guild!.roles.fetch(role_id); const role_name = role?.name ?? ""; rules.push({ group_id, role_name, include_historical }); } await interaction.reply(message_command_response_rule_list_success(rules)); } private async handle_command_rule_add(interaction: ChatInputCommandInteraction) { const guild_id = interaction.guildId; if (guild_id === null) { await interaction.reply( message_command_response_list_error("Cette commande peut être exécutée uniquement sur un serveur."), ); return; } const group_id = interaction.options.getString("group", true); const role = interaction.options.getRole("role", true); const include_historical = interaction.options.getBoolean("incl_historical", true); // TODO : test if role exists const role_id = role.id; const role_name = role.name; await this.rules.append_rule(group_id, { guild_id, role_id }, include_historical); await interaction.reply(message_command_response_rule_add_success({ group_id, role_name, include_historical })); } private async handle_command_rule_remove(interaction: ChatInputCommandInteraction) { const guild_id = interaction.guildId; if (guild_id === null) { await interaction.reply( message_command_response_list_error("Cette commande peut être exécutée uniquement sur un serveur."), ); return; } const group_id = interaction.options.getString("group", true); const removed = await this.rules.remove_rules(group_id, guild_id); const rules = [] as GuildRule[]; for (const { group_id, target_role: { role_id }, include_historical } of removed) { const role = await interaction.guild!.roles.fetch(role_id); const role_name = role?.name ?? ""; rules.push({ group_id, role_name, include_historical }); } await interaction.reply(message_command_response_rule_remove_success(rules)); } } function message_command_response_assoc_pending(email: string) { const embed = new EmbedBuilder() .setTitle("`🟡`| Association") .setDescription(` Un lien de vérification a été envoyé par e-mail à \`${email}\`. Le lien expirera dans 5 minutes. **Il pourraît être arrivé dans \`Courrier indésirable\`.** `.trim()); return { embeds: [embed] }; } function message_command_response_assoc_error(msg: string) { const embed = new EmbedBuilder() .setTitle("`❌`| Association") .setDescription(`Échec de l'association :\n\`${msg}\``); return { embeds: [embed] }; } function message_command_response_assoc_success() { const embed = new EmbedBuilder() .setTitle("`✅`| Association") .setDescription("Votre e-mail CRI a bien été associé à ce compte Discord."); return { embeds: [embed] }; } function message_command_response_list_error(msg: string) { const embed = new EmbedBuilder() .setTitle("`❌`| Liste des règles") .setDescription(`Échec :\n\`${msg}\``); return { embeds: [embed] }; } type GuildRule = { group_id: string; role_name: string; include_historical: boolean }; function message_command_response_rule_list_success(rules: GuildRule[]) { const embed = new EmbedBuilder() .setTitle("`📚️`| Liste des règles") .setDescription(`Règles d'associations groupe / rôle. ${rules.map(display_rule).join("\n")} `); return { embeds: [embed] }; } function message_command_response_rule_add_success(rule: GuildRule) { const embed = new EmbedBuilder() .setTitle("`✅`| Règle ajouté") .setDescription(`Règle d'association ajoutée : ${display_rule(rule)} `); return { embeds: [embed] }; } function message_command_response_rule_remove_success(rules: GuildRule[]) { const embed = new EmbedBuilder() .setTitle("`✅`| Règle ajouté") .setDescription(`Règle d'association retirées : ${rules.map(display_rule).join("\n")} `); return { embeds: [embed] }; } function to_comparable(input: string) { return input .toLowerCase() .normalize("NFD") // normalize accents .replace(/\p{Diacritic}/gu, ""); // remotes accents } function display_rule(rule: GuildRule) { if (rule.include_historical) return `- \`${rule.group_id}\` [H] → \`@${rule.role_name}\``; else return `- \`${rule.group_id}\` → \`@${rule.role_name}\``; }