diff --git a/src/bot.ts b/src/bot.ts index b50ee85..09184e5 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,6 +1,5 @@ import { CacheType, - ChatInputCommandInteraction, Client, EmbedBuilder, GatewayIntentBits, @@ -11,9 +10,6 @@ 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); /** @@ -23,16 +19,14 @@ export class EpitlsBot { private bot; private token; private rest; - private rules; private assoc_channel; - public constructor(bot_token: string, rules: RuleSet) { + public constructor(bot_token: string) { 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 } >(); @@ -54,16 +48,12 @@ export class EpitlsBot { * Assigns a discord role to a discord user. */ public async assign_role(user_id: string, guild_id: string, role_id: string) { - 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 (_) { - // - } + 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}'.`); } /** @@ -90,78 +80,30 @@ export class EpitlsBot { } private async register_commands() { - const assoc_cmd = new SlashCommandBuilder() + const 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") + .setDescription("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: [assoc_cmd, rule_cmd] }, + { body: [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) { + if (interaction.commandName != "associate") return; const email = interaction.options.getString("cri_email")!; if (!email.endsWith("@epita.fr")) { - await interaction.reply(message_command_response_assoc_error("invalid cri email " + email)); + await interaction.reply(message_command_response_error("invalid cri email " + email)); return; } const [cri_login] = email.split("@epita.fr"); @@ -169,70 +111,17 @@ 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_assoc_pending(email)); + await interaction.reply(message_command_response_sending_email(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 } 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)); + if (result === true) interaction.editReply(message_command_response_success()); + else interaction.editReply(message_command_response_error(result)); } } -function message_command_response_assoc_pending(email: string) { +function message_command_response_sending_email(email: string) { const embed = new EmbedBuilder() - .setTitle("`🟡`| Association") + .setTitle("`🟡`| Vérification") .setDescription(` Un lien de vérification a été envoyé par e-mail à \`${email}\`. Le lien expirera dans 5 minutes. @@ -241,53 +130,16 @@ Le lien expirera dans 5 minutes. return { embeds: [embed] }; } -function message_command_response_assoc_error(msg: string) { +function message_command_response_error(msg: string) { const embed = new EmbedBuilder() - .setTitle("`❌`| Association") - .setDescription(`Échec de l'association :\n\`${msg}\``); + .setTitle("`❌`| Vérification") + .setDescription(`Échec de la vérification :\n\`${msg}\``); return { embeds: [embed] }; } -function message_command_response_assoc_success() { +function message_command_response_success() { const embed = new EmbedBuilder() - .setTitle("`✅`| Association") + .setTitle("`✅`| Vérification") .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 54c00ea..7234b0d 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, rules); + const bot = new EpitlsBot(conf.discord.bot_token); await bot.start(); log(`Started bot '${bot.bot_name()}' .`); @@ -73,13 +73,6 @@ 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() { @@ -93,7 +86,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 2240762..f609a0d 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -1,5 +1,4 @@ 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 }; @@ -8,84 +7,48 @@ export type TargetRole = { guild_id: string; role_id: string }; */ export class RuleSet { private rules; - private file; - private update_channel; - public constructor(file: string) { - this.rules = [] as SerializedRule[]; - this.file = file; - this.update_channel = channel<{ updated_groups: string[] }>(); + public constructor() { + this.rules = new Map(); } /** * Reads a RuleSet from a JSON serialized file. */ public static async from_file(path: string) { - const result = new RuleSet(path); + const result = new RuleSet(); const file_content = await Deno.readTextFile(path); const parsed = parse_rules(file_content); - for (const { group_id, target_role } of parsed) await result.append_rule(group_id, target_role); + for (const { group_id, target_role } of parsed) 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) { - 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 }; - } + public roles_for_group(group_id: string) { + return this.rules.get(group_id) ?? []; } /** * Number of managed Discord roles. */ public size() { - return this.rules.length; + let result = 0; + for (const roles of this.rules.values()) result += roles.length; + return result; } - 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; + 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); } - 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); + roles.push(target_role); } } -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 8485cfc..b449060 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -112,13 +112,3 @@ 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 55069cb..0898175 100644 --- a/src/verifier.ts +++ b/src/verifier.ts @@ -53,22 +53,11 @@ export class WebVerifier { private serve(request: Request): Response { const url = new URL(request.url); - 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."); - } + if (!url.pathname.startsWith("/verify/")) return response_failure("Invalid path."); - 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("Lien de vérification invalide."); + if (resolver === undefined) return response_failure("Invalid verification link."); resolver(true); this.awaiting.delete(uid); @@ -76,20 +65,12 @@ 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); @@ -101,55 +82,34 @@ export class WebVerifier { } function response_failure(message: string) { - 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. -
- -
-`); + const body = ` + + + Epitls • Verification + + +
+❌ Failure :
+${message}    
+
+ + +`; return new Response(body, { headers: { "Content-Type": "text/html" } }); } function response_success() { - 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 ` + const body = ` Epitls • Verification
-${content}
+✅ Verified successfully.    
 
`; + return new Response(body, { headers: { "Content-Type": "text/html" } }); }