Compare commits

..

No commits in common. "9dda4507283094e295c1f77e4c7cd49aab5e1b22" and "bfb53d5c64eb21eb32943ab168137f9cec1ee493" have entirely different histories.

5 changed files with 57 additions and 299 deletions

View file

@ -1,6 +1,5 @@
import { import {
CacheType, CacheType,
ChatInputCommandInteraction,
Client, Client,
EmbedBuilder, EmbedBuilder,
GatewayIntentBits, GatewayIntentBits,
@ -11,9 +10,6 @@ import {
SlashCommandStringOption, SlashCommandStringOption,
} from "npm:discord.js@14.14.1"; } from "npm:discord.js@14.14.1";
import { channel, log_from, SimpleResult, split_promise } from "./utils.ts"; 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); const log = log_from(import.meta.url);
/** /**
@ -23,16 +19,14 @@ export class EpitlsBot {
private bot; private bot;
private token; private token;
private rest; private rest;
private rules;
private assoc_channel; private assoc_channel;
public constructor(bot_token: string, rules: RuleSet) { public constructor(bot_token: string) {
this.token = bot_token; this.token = bot_token;
const intents = [GatewayIntentBits.Guilds]; const intents = [GatewayIntentBits.Guilds];
this.bot = new Client({ intents }); this.bot = new Client({ intents });
this.bot.on("interactionCreate", (interaction) => this.handle_interaction(interaction)); this.bot.on("interactionCreate", (interaction) => this.handle_interaction(interaction));
this.rest = new REST().setToken(bot_token); this.rest = new REST().setToken(bot_token);
this.rules = rules;
this.assoc_channel = channel< this.assoc_channel = channel<
{ cri_login: string; discord_user_id: string; callback: (success: SimpleResult) => void } { 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. * Assigns a discord role to a discord user.
*/ */
public async assign_role(user_id: string, guild_id: string, role_id: string) { public async assign_role(user_id: string, guild_id: string, role_id: string) {
try {
const guild = await this.bot.guilds.fetch(guild_id); const guild = await this.bot.guilds.fetch(guild_id);
const member = await guild.members.fetch({ user: user_id }); const member = await guild.members.fetch({ user: user_id });
const role = await guild.roles.fetch(role_id); const role = await guild.roles.fetch(role_id);
if (role === null) return console.error("Role", role_id, "not found in guild", guild_id); if (role === null) return console.error("Role", role_id, "not found in guild", guild_id);
member.roles.add(role_id); member.roles.add(role_id);
log(`Assigned role '${role.name}' to user '${member.displayName}'.`); log(`Assigned role '${role.name}' to user '${member.displayName}'.`);
} catch (_) {
//
}
} }
/** /**
@ -90,78 +80,30 @@ export class EpitlsBot {
} }
private async register_commands() { private async register_commands() {
const assoc_cmd = new SlashCommandBuilder() const cmd = new SlashCommandBuilder()
.setName("associate") .setName("associate")
.setDescription("Associates a cri account to your discord account.") .setDescription("Associates a cri account to your discord account.")
.addStringOption( .addStringOption(
new SlashCommandStringOption() new SlashCommandStringOption()
.setName("cri_email") .setName("cri_email")
.setDescription("par exemple, claude.nougaro@epita.fr") .setDescription("claude.nougaro@epita.fr")
.setRequired(true), .setRequired(true),
).toJSON(); ).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()) { for (const guild_id of this.bot.guilds.cache.keys()) {
await this.rest.put( await this.rest.put(
Routes.applicationGuildCommands(this.bot.application!.id, guild_id), Routes.applicationGuildCommands(this.bot.application!.id, guild_id),
{ body: [assoc_cmd, rule_cmd] }, { body: [cmd] },
); );
} }
} }
private async handle_interaction(interaction: Interaction<CacheType>) { private async handle_interaction(interaction: Interaction<CacheType>) {
if (!interaction.isChatInputCommand()) return; if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === "associate") await this.handle_command_associate(interaction); if (interaction.commandName != "associate") return;
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<CacheType>) {
const email = interaction.options.getString("cri_email")!; const email = interaction.options.getString("cri_email")!;
if (!email.endsWith("@epita.fr")) { 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; return;
} }
const [cri_login] = email.split("@epita.fr"); const [cri_login] = email.split("@epita.fr");
@ -169,70 +111,17 @@ export class EpitlsBot {
const { promise, resolver } = split_promise<SimpleResult>(); const { promise, resolver } = split_promise<SimpleResult>();
this.assoc_channel.send({ cri_login, discord_user_id, callback: resolver }); 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}'.`); log(`Started verification for discord id '${discord_user_id}' with cri login '${cri_login}'.`);
const result = await promise; const result = await promise;
if (result === true) interaction.editReply(message_command_response_assoc_success()); if (result === true) interaction.editReply(message_command_response_success());
else interaction.editReply(message_command_response_assoc_error(result)); else interaction.editReply(message_command_response_error(result));
}
private async handle_command_rule_list(interaction: ChatInputCommandInteraction<CacheType>) {
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 ?? "<DELETED>";
rules.push({ group_id, role_name });
}
await interaction.reply(message_command_response_rule_list_success(rules));
}
private async handle_command_rule_add(interaction: ChatInputCommandInteraction<CacheType>) {
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<CacheType>) {
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 ?? "<DELETED>";
rules.push({ group_id, role_name });
}
await interaction.reply(message_command_response_rule_remove_success(rules));
} }
} }
function message_command_response_assoc_pending(email: string) { function message_command_response_sending_email(email: string) {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle("`🟡`| Association") .setTitle("`🟡`| Vérification")
.setDescription(` .setDescription(`
Un lien de vérification a é envoyé par e-mail à \`${email}\`. Un lien de vérification a é envoyé par e-mail à \`${email}\`.
Le lien expirera dans 5 minutes. Le lien expirera dans 5 minutes.
@ -241,53 +130,16 @@ Le lien expirera dans 5 minutes.
return { embeds: [embed] }; return { embeds: [embed] };
} }
function message_command_response_assoc_error(msg: string) { function message_command_response_error(msg: string) {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle("`❌`| Association") .setTitle("`❌`| Vérification")
.setDescription(`Échec de l'association :\n\`${msg}\``); .setDescription(`Échec de la vérification :\n\`${msg}\``);
return { embeds: [embed] }; return { embeds: [embed] };
} }
function message_command_response_assoc_success() { function message_command_response_success() {
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle("`✅`| Association") .setTitle("`✅`| Vérification")
.setDescription("Votre e-mail CRI a bien été associé à ce compte Discord."); .setDescription("Votre e-mail CRI a bien été associé à ce compte Discord.");
return { embeds: [embed] }; 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] };
}

View file

@ -18,7 +18,7 @@ async function main() {
const state = await State.from_dir(root_path() + "/local"); const state = await State.from_dir(root_path() + "/local");
log("Loaded state with", await state.users_count(), "users."); 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(); await bot.start();
log(`Started bot '${bot.bot_name()}' .`); log(`Started bot '${bot.bot_name()}' .`);
@ -73,13 +73,6 @@ class Service {
this.emailer.send_confirmation_mail(username ?? "<unknown>", cri_login + "@epita.fr", link); this.emailer.send_confirmation_mail(username ?? "<unknown>", 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() { async update_all_users_roles() {
@ -93,7 +86,7 @@ class Service {
async update_user_roles(cri_login: string, discord_user_id: string) { async update_user_roles(cri_login: string, discord_user_id: string) {
const groups = await this.cri_api.groups_of(cri_login); const groups = await this.cri_api.groups_of(cri_login);
// log("found groups", groups); // 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); // 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); for (const { guild_id, role_id } of roles) await this.bot.assign_role(discord_user_id, guild_id, role_id);
} }

View file

@ -1,5 +1,4 @@
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts"; 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 }; 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 { export class RuleSet {
private rules; private rules;
private file;
private update_channel;
public constructor(file: string) { public constructor() {
this.rules = [] as SerializedRule[]; this.rules = new Map<string, TargetRole[]>();
this.file = file;
this.update_channel = channel<{ updated_groups: string[] }>();
} }
/** /**
* Reads a RuleSet from a JSON serialized file. * Reads a RuleSet from a JSON serialized file.
*/ */
public static async from_file(path: string) { public static async from_file(path: string) {
const result = new RuleSet(path); const result = new RuleSet();
const file_content = await Deno.readTextFile(path); const file_content = await Deno.readTextFile(path);
const parsed = parse_rules(file_content); 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; return result;
} }
/** /**
* Gets which Discord roles must be assigned to a user which is part of a given CRI group. * 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) { public roles_for_group(group_id: string) {
for (const rule of this.rules) if (rule.group_id === group_id) yield rule.target_role; return this.rules.get(group_id) ?? [];
}
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. * Number of managed Discord roles.
*/ */
public size() { 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) { private append_rule(group_id: string, target_role: TargetRole) {
for (const r of this.rules) { let roles = this.rules.get(group_id);
const same_group = r.group_id === group_id; if (roles === undefined) {
const same_target_role = r.target_role.guild_id === target_role.guild_id && roles = [];
r.target_role.role_id === target_role.role_id; this.rules.set(group_id, roles);
if (same_group && same_target_role) return;
} }
this.rules.push({ group_id, target_role }); roles.push(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<typeof parse_rules>[0];
function parse_rules(content: string) { function parse_rules(content: string) {
return z.array(z.object({ return z.array(z.object({
group_id: z.string(), group_id: z.string(),

View file

@ -112,13 +112,3 @@ export async function wait(ms: number) {
setTimeout(resolver, ms); setTimeout(resolver, ms);
await promise; await promise;
} }
export function* enumerate<T>(items: Iterable<T>) {
let index = 0;
for (const item of items) yield [item, index++] as const;
}
export function* reverse<T>(items: Iterable<T>) {
const collected = [...items];
for (const item of collected.reverse()) yield item;
}

View file

@ -53,22 +53,11 @@ export class WebVerifier {
private serve(request: Request): Response { private serve(request: Request): Response {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname.startsWith("/verify/")) return this.serve_verification_page(url); if (!url.pathname.startsWith("/verify/")) return response_failure("Invalid path.");
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 [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); 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); resolver(true);
this.awaiting.delete(uid); this.awaiting.delete(uid);
@ -76,20 +65,12 @@ export class WebVerifier {
return response_success(); return response_success();
} }
private async serve_result_page() {
//
}
private create_verif_link() { private create_verif_link() {
const uid = uuid.generate() as string; const uid = uuid.generate() as string;
const link = `http://${this.conf.url_prefix}/verify/${uid}`; const link = `http://${this.conf.url_prefix}/verify/${uid}`;
return { uid, link }; return { uid, link };
} }
private create_confirm_link(uid: string) {
return `http://${this.conf.url_prefix}/confirm/${uid}`;
}
private async start_peremption(uid: string) { private async start_peremption(uid: string) {
await wait(5 * 60 * 1000); // 5 mins await wait(5 * 60 * 1000); // 5 mins
const resolver = this.awaiting.get(uid); const resolver = this.awaiting.get(uid);
@ -101,55 +82,34 @@ export class WebVerifier {
} }
function response_failure(message: string) { function response_failure(message: string) {
const body = html_pre(` const body = `<!DOCTYPE html>
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.
<form action="${confirm_link}">
<input type="submit" value="Confirmer">
</form>
`);
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 `<!DOCTYPE html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>Epitls Verification</title> <title>Epitls Verification</title>
</head> </head>
<body> <body>
<pre> <pre>
${content} Failure :
${message}
</pre> </pre>
</body> </body>
</html> </html>
`; `;
return new Response(body, { headers: { "Content-Type": "text/html" } });
}
function response_success() {
const body = `<!DOCTYPE html>
<head>
<meta charset="UTF-8">
<title>Epitls Verification</title>
</head>
<body>
<pre>
Verified successfully.
</pre>
</body>
</html>
`;
return new Response(body, { headers: { "Content-Type": "text/html" } });
} }