diff --git a/src/bot.ts b/src/bot.ts index 09184e5..b50ee85 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,5 +1,6 @@ import { CacheType, + ChatInputCommandInteraction, Client, EmbedBuilder, GatewayIntentBits, @@ -10,6 +11,9 @@ import { SlashCommandStringOption, } from "npm:discord.js@14.14.1"; import { channel, log_from, SimpleResult, split_promise } from "./utils.ts"; +import { RuleSet } from "./rules.ts"; +import { SlashCommandSubcommandBuilder } from "npm:discord.js@14.14.1"; +import { SlashCommandRoleOption } from "npm:discord.js@14.14.1"; const log = log_from(import.meta.url); /** @@ -19,14 +23,16 @@ export class EpitlsBot { private bot; private token; private rest; + private rules; private assoc_channel; - public constructor(bot_token: string) { + 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 } >(); @@ -48,12 +54,16 @@ export class EpitlsBot { * 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 guild.members.fetch({ user: user_id }); - const role = await guild.roles.fetch(role_id); - if (role === null) return console.error("Role", role_id, "not found in guild", guild_id); - member.roles.add(role_id); - log(`Assigned role '${role.name}' to user '${member.displayName}'.`); + try { + const guild = await this.bot.guilds.fetch(guild_id); + const member = await guild.members.fetch({ user: user_id }); + const role = await guild.roles.fetch(role_id); + if (role === null) return console.error("Role", role_id, "not found in guild", guild_id); + member.roles.add(role_id); + log(`Assigned role '${role.name}' to user '${member.displayName}'.`); + } catch (_) { + // + } } /** @@ -80,30 +90,78 @@ export class EpitlsBot { } private async register_commands() { - const cmd = new SlashCommandBuilder() + const assoc_cmd = new SlashCommandBuilder() .setName("associate") .setDescription("Associates a cri account to your discord account.") .addStringOption( new SlashCommandStringOption() .setName("cri_email") - .setDescription("claude.nougaro@epita.fr") + .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), + ), + ) + .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: [cmd] }, + { body: [assoc_cmd, rule_cmd] }, ); } } private async handle_interaction(interaction: Interaction) { if (!interaction.isChatInputCommand()) return; - if (interaction.commandName != "associate") 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_error("invalid cri email " + email)); + await interaction.reply(message_command_response_assoc_error("invalid cri email " + email)); return; } const [cri_login] = email.split("@epita.fr"); @@ -111,17 +169,70 @@ export class EpitlsBot { const { promise, resolver } = split_promise(); this.assoc_channel.send({ cri_login, discord_user_id, callback: resolver }); - await interaction.reply(message_command_response_sending_email(email)); + 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_success()); - else interaction.editReply(message_command_response_error(result)); + 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 } 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 }); + } + 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); + // 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 }); + await interaction.reply(message_command_response_rule_add_success({ group_id, role_name })); + } + + 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 } } of removed) { + const role = await interaction.guild!.roles.fetch(role_id); + const role_name = role?.name ?? ""; + rules.push({ group_id, role_name }); + } + await interaction.reply(message_command_response_rule_remove_success(rules)); } } -function message_command_response_sending_email(email: string) { +function message_command_response_assoc_pending(email: string) { const embed = new EmbedBuilder() - .setTitle("`🟡`| Vérification") + .setTitle("`🟡`| Association") .setDescription(` Un lien de vérification a été envoyé par e-mail à \`${email}\`. Le lien expirera dans 5 minutes. @@ -130,16 +241,53 @@ Le lien expirera dans 5 minutes. return { embeds: [embed] }; } -function message_command_response_error(msg: string) { +function message_command_response_assoc_error(msg: string) { const embed = new EmbedBuilder() - .setTitle("`❌`| Vérification") - .setDescription(`Échec de la vérification :\n\`${msg}\``); + .setTitle("`❌`| Association") + .setDescription(`Échec de l'association :\n\`${msg}\``); return { embeds: [embed] }; } -function message_command_response_success() { +function message_command_response_assoc_success() { const embed = new EmbedBuilder() - .setTitle("`✅`| Vérification") + .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 }; +function message_command_response_rule_list_success(rules: GuildRule[]) { + const rule_list = rules.map((r) => `- \`${r.group_id}\` → \`@${r.role_name}\``).join("\n"); + const embed = new EmbedBuilder() + .setTitle("`📚️`| Liste des règles") + .setDescription(`Règles d'associations groupe / rôle. +${rule_list} +`); + 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 : +- \`${rule.group_id}\` → \`@${rule.role_name}\` +`); + return { embeds: [embed] }; +} + +function message_command_response_rule_remove_success(rules: GuildRule[]) { + const rule_list = rules.map((r) => `- \`${r.group_id}\` → \`@${r.role_name}\``).join("\n"); + const embed = new EmbedBuilder() + .setTitle("`✅`| Règle ajouté") + .setDescription(`Règle d'association retirées : +${rule_list} +`); + return { embeds: [embed] }; +} diff --git a/src/main.ts b/src/main.ts index 7234b0d..54c00ea 100755 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,7 @@ async function main() { const state = await State.from_dir(root_path() + "/local"); log("Loaded state with", await state.users_count(), "users."); - const bot = new EpitlsBot(conf.discord.bot_token); + const bot = new EpitlsBot(conf.discord.bot_token, rules); await bot.start(); log(`Started bot '${bot.bot_name()}' .`); @@ -73,6 +73,13 @@ class Service { this.emailer.send_confirmation_mail(username ?? "", cri_login + "@epita.fr", link); } })(); + + // for all updates to rule set, reassign all roles. + (async () => { + for await (const _ of this.rules.receive_rule_update()) { + await this.update_all_users_roles(); + } + }); } async update_all_users_roles() { @@ -86,7 +93,7 @@ class Service { async update_user_roles(cri_login: string, discord_user_id: string) { const groups = await this.cri_api.groups_of(cri_login); // log("found groups", groups); - const roles = groups.map((group) => this.rules.roles_for_group(group)).flat(); + const roles = groups.map((group) => [...this.rules.roles_for_group(group)]).flat(); // log("found setting roles", roles); for (const { guild_id, role_id } of roles) await this.bot.assign_role(discord_user_id, guild_id, role_id); } diff --git a/src/rules.ts b/src/rules.ts index f609a0d..2240762 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -1,4 +1,5 @@ import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; +import { channel } from "./utils.ts"; export type TargetRole = { guild_id: string; role_id: string }; @@ -7,48 +8,84 @@ export type TargetRole = { guild_id: string; role_id: string }; */ export class RuleSet { private rules; + private file; + private update_channel; - public constructor() { - this.rules = new Map(); + public constructor(file: string) { + this.rules = [] as SerializedRule[]; + this.file = file; + this.update_channel = channel<{ updated_groups: string[] }>(); } /** * Reads a RuleSet from a JSON serialized file. */ public static async from_file(path: string) { - const result = new RuleSet(); + const result = new RuleSet(path); const file_content = await Deno.readTextFile(path); const parsed = parse_rules(file_content); - for (const { group_id, target_role } of parsed) result.append_rule(group_id, target_role); + for (const { group_id, target_role } of parsed) await result.append_rule(group_id, target_role); return result; } /** * Gets which Discord roles must be assigned to a user which is part of a given CRI group. */ - public roles_for_group(group_id: string) { - return this.rules.get(group_id) ?? []; + public *roles_for_group(group_id: string) { + for (const rule of this.rules) if (rule.group_id === group_id) yield rule.target_role; + } + + public *roles_for_guild(target_guild_id: string) { + for (const { group_id, target_role: { guild_id, role_id } } of this.rules) { + if (target_guild_id === guild_id) yield { group_id, role_id }; + } } /** * Number of managed Discord roles. */ public size() { - let result = 0; - for (const roles of this.rules.values()) result += roles.length; - return result; + return this.rules.length; } - private append_rule(group_id: string, target_role: TargetRole) { - let roles = this.rules.get(group_id); - if (roles === undefined) { - roles = []; - this.rules.set(group_id, roles); + public async append_rule(group_id: string, target_role: TargetRole) { + for (const r of this.rules) { + const same_group = r.group_id === group_id; + const same_target_role = r.target_role.guild_id === target_role.guild_id && + r.target_role.role_id === target_role.role_id; + if (same_group && same_target_role) return; } - roles.push(target_role); + this.rules.push({ group_id, target_role }); + await this.save(); + this.update_channel.send({ updated_groups: [group_id] }); + } + + public async remove_rules(group_id: string, guild_id: string) { + const removed = [] as SerializedRule[]; + const kept = [] as SerializedRule[]; + for (const rule of this.rules) { + const is_deleted_group = rule.group_id === group_id; + const is_from_guild = rule.target_role.guild_id = guild_id; + if (is_deleted_group && is_from_guild) removed.push(rule); + else kept.push(rule); + } + this.rules = kept; + await this.save(); + this.update_channel.send({ updated_groups: removed.map((r) => r.group_id) }); + return removed; + } + + public async *receive_rule_update() { + while (true) yield await this.update_channel.receive(); + } + + private async save() { + const serialized = JSON.stringify(this.rules, null, 4); + await Deno.writeTextFile(this.file, serialized); } } +type SerializedRule = ReturnType[0]; function parse_rules(content: string) { return z.array(z.object({ group_id: z.string(), diff --git a/src/utils.ts b/src/utils.ts index b449060..8485cfc 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -112,3 +112,13 @@ export async function wait(ms: number) { setTimeout(resolver, ms); await promise; } + +export function* enumerate(items: Iterable) { + let index = 0; + for (const item of items) yield [item, index++] as const; +} + +export function* reverse(items: Iterable) { + const collected = [...items]; + for (const item of collected.reverse()) yield item; +} diff --git a/src/verifier.ts b/src/verifier.ts index 0898175..55069cb 100644 --- a/src/verifier.ts +++ b/src/verifier.ts @@ -53,11 +53,22 @@ export class WebVerifier { private serve(request: Request): Response { const url = new URL(request.url); - if (!url.pathname.startsWith("/verify/")) return response_failure("Invalid path."); + if (url.pathname.startsWith("/verify/")) return this.serve_verification_page(url); + if (url.pathname.startsWith("/confirm/")) return this.serve_confirmation(url); + return response_failure("Chemin invalide."); + } + private serve_verification_page(url: URL) { const [uid] = url.pathname.slice("/verify/".length).split("/"); + const exists = this.awaiting.has(uid); + if (!exists) return response_failure("Lien de vérification invalide."); + return response_verification_page(this.create_confirm_link(uid)); + } + + private serve_confirmation(url: URL) { + const [uid] = url.pathname.slice("/confirm/".length).split("/"); const resolver = this.awaiting.get(uid); - if (resolver === undefined) return response_failure("Invalid verification link."); + if (resolver === undefined) return response_failure("Lien de vérification invalide."); resolver(true); this.awaiting.delete(uid); @@ -65,12 +76,20 @@ export class WebVerifier { return response_success(); } + private async serve_result_page() { + // + } + private create_verif_link() { const uid = uuid.generate() as string; const link = `http://${this.conf.url_prefix}/verify/${uid}`; return { uid, link }; } + private create_confirm_link(uid: string) { + return `http://${this.conf.url_prefix}/confirm/${uid}`; + } + private async start_peremption(uid: string) { await wait(5 * 60 * 1000); // 5 mins const resolver = this.awaiting.get(uid); @@ -82,34 +101,55 @@ export class WebVerifier { } function response_failure(message: string) { - const body = ` - - - Epitls • Verification - - -
-❌ Failure :
-${message}    
-
- - -`; + const body = html_pre(` +EPITLS BOT +========== + +❌ Échec +--------- +${message} +`); + return new Response(body, { headers: { "Content-Type": "text/html" } }); +} + +function response_verification_page(confirm_link: string) { + const body = html_pre(` +EPITLS BOT +========== + +🟡 Vérification +---------------- + +Vous êtes sur le point de confirmer l'association. +
+ +
+`); return new Response(body, { headers: { "Content-Type": "text/html" } }); } function response_success() { - const body = ` + const body = html_pre(` +EPITLS BOT +========== + +✅ Vérifié avec succès +----------------------- +`); + return new Response(body, { headers: { "Content-Type": "text/html" } }); +} + +function html_pre(content: string) { + return ` Epitls • Verification
-✅ Verified successfully.    
+${content}
 
`; - return new Response(body, { headers: { "Content-Type": "text/html" } }); }