diff --git a/src/bot.ts b/src/bot.ts index b50ee85..39de1c3 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -4,16 +4,18 @@ import { Client, EmbedBuilder, GatewayIntentBits, + Guild, Interaction, REST, Routes, + SlashCommandBooleanOption, SlashCommandBuilder, + SlashCommandRoleOption, SlashCommandStringOption, + SlashCommandSubcommandBuilder, } 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"; +import { channel, log_from, SimpleResult, split_promise, try_ } from "./utils.ts"; const log = log_from(import.meta.url); /** @@ -54,15 +56,34 @@ 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 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); + member.roles.add(role_id); + log(`Assigned 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_(async () => await user.setNickname(generated_name)); + } + } + + private async try_fetch_member(guild: Guild, user_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}'.`); + return await guild.members.fetch({ user: user_id }); } catch (_) { - // + return undefined; } } @@ -124,6 +145,12 @@ export class EpitlsBot { .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( @@ -185,10 +212,10 @@ export class EpitlsBot { return; } const rules = [] as GuildRule[]; - for (const { group_id, role_id } of this.rules.roles_for_guild(guild_id)) { + 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 }); + rules.push({ group_id, role_name, include_historical }); } await interaction.reply(message_command_response_rule_list_success(rules)); } @@ -203,11 +230,12 @@ export class EpitlsBot { } 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 }); - await interaction.reply(message_command_response_rule_add_success({ group_id, 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) { @@ -221,10 +249,10 @@ export class EpitlsBot { 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) { + 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 }); + rules.push({ group_id, role_name, include_historical }); } await interaction.reply(message_command_response_rule_remove_success(rules)); } @@ -262,13 +290,12 @@ function message_command_response_list_error(msg: string) { return { embeds: [embed] }; } -type GuildRule = { group_id: string; role_name: string }; +type GuildRule = { group_id: string; role_name: string; include_historical: boolean }; 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} +${rules.map(display_rule).join("\n")} `); return { embeds: [embed] }; } @@ -277,17 +304,28 @@ 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}\` +${display_rule(rule)} `); 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} +${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}\``; +} diff --git a/src/cri.ts b/src/cri.ts index 75e1fff..00b4580 100644 --- a/src/cri.ts +++ b/src/cri.ts @@ -31,11 +31,15 @@ export class CriApi { current_groups: z.array(group), }); const parsed = parser.parse(response); - const result = new Set(); - result.add(parsed.primary_group.slug); - for (const group of parsed.current_groups) result.add(group.slug); - for (const { group } of parsed.groups_history) result.add(group.slug); - return Array.from(result.values()); + const current = new Set(); + const history = new Set(); + current.add(parsed.primary_group.slug); + for (const group of parsed.current_groups) current.add(group.slug); + for (const { group } of parsed.groups_history) history.add(group.slug); + return { + current: Array.from(current.values()), + history: Array.from(history.values()), + }; } /** @@ -53,6 +57,17 @@ export class CriApi { return found; } + public async get_user_name(login: string) { + log(`Fetching username of '${login}'.`); + const response = await fetch_json_auth(`https://cri.epita.fr/api/v2/users/${login}/`, this.token); + const parser = z.object({ + first_name: z.string(), + last_name: z.string(), + }); + const parsed = parser.parse(response); + return parsed; + } + public async get_all_groups() { const result = [] as string[]; const response_parser = z.object({ diff --git a/src/main.ts b/src/main.ts index 90de2ac..c6ffc86 100755 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { log_from, read_conf, root_path, SimpleResult } from "./utils.ts"; import { State } from "./state.ts"; -import { RuleSet } from "./rules.ts"; +import { RuleSet, TargetRole } from "./rules.ts"; import { EpitlsBot } from "./bot.ts"; import { CriApi } from "./cri.ts"; import { Emailer } from "./email.ts"; @@ -86,6 +86,7 @@ class Service { const promises = [] as Promise[]; for await (const { cri_login, discord_user_id } of this.state.users()) { promises.push(this.update_user_roles(cri_login, discord_user_id)); + promises.push(this.update_user_name(cri_login, discord_user_id)); } await Promise.all(promises); } @@ -93,11 +94,20 @@ 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 = [] as TargetRole[]; + + for (const group of groups.current) roles.push(...this.rules.roles_for_group(group, false)); + for (const group of groups.history) roles.push(...this.rules.roles_for_group(group, true)); // 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); } + async update_user_name(cri_login: string, discord_user_id: string) { + const { first_name, last_name } = await this.cri_api.get_user_name(cri_login); + const suffix = ` [${first_name} .${last_name[0] ?? ""}]`; + await this.bot.update_user_name(discord_user_id, first_name, suffix); + } + async association_procedure(discord_user_id: string, cri_login: string): Promise { try { if (!await this.cri_api.user_exists(cri_login)) return "No such login."; diff --git a/src/rules.ts b/src/rules.ts index efffdd6..f5ca53b 100644 --- a/src/rules.ts +++ b/src/rules.ts @@ -25,7 +25,9 @@ export class 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) await result.append_rule(group_id, target_role); + for (const { group_id, target_role, include_historical } of parsed) { + await result.append_rule(group_id, target_role, include_historical); + } result.update_channel = channel(); return result; } @@ -33,13 +35,17 @@ export class RuleSet { /** * 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_group(group_id: string, historical: boolean) { + for (const rule of this.rules) { + if (rule.group_id !== group_id) continue; + if (!rule.include_historical && historical) continue; + 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 }; + for (const { group_id, target_role: { guild_id, role_id }, include_historical } of this.rules) { + if (target_guild_id === guild_id) yield { group_id, role_id, include_historical }; } } @@ -50,14 +56,14 @@ export class RuleSet { return this.rules.length; } - public async append_rule(group_id: string, target_role: TargetRole) { + public async append_rule(group_id: string, target_role: TargetRole, include_historical: boolean) { 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; } - this.rules.push({ group_id, target_role }); + this.rules.push({ group_id, target_role, include_historical }); await this.save(); this.update_channel.send({ updated_groups: [group_id] }); } @@ -96,5 +102,6 @@ function parse_rules(content: string) { return z.array(z.object({ group_id: z.string(), target_role: z.object({ guild_id: z.string(), role_id: z.string() }), + include_historical: z.boolean().default(false), })).parse(JSON.parse(content)); } diff --git a/src/utils.ts b/src/utils.ts index 8485cfc..84e09a6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -122,3 +122,11 @@ export function* reverse(items: Iterable) { const collected = [...items]; for (const item of collected.reverse()) yield item; } + +export async function try_(f: () => Promise | T) { + try { + return await f(); + } catch (_) { + return undefined; + } +}