This commit is contained in:
Matthieu Jolimaitre 2024-02-01 22:54:21 +01:00
commit c872cad278
11 changed files with 1052 additions and 0 deletions

111
src/bot.ts Normal file
View file

@ -0,0 +1,111 @@
import {
CacheType,
Client,
EmbedBuilder,
GatewayIntentBits,
Interaction,
REST,
Routes,
SlashCommandBuilder,
SlashCommandStringOption,
} from "npm:discord.js@14.14.1";
import { channel, log_from, SimpleResult, split_promise } from "./utils.ts";
const log = (...args: unknown[]) => log_from(import.meta.url, ...args);
export class EpitlsBot {
bot;
token;
rest;
assoc_channel;
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.assoc_channel = channel<
{ cri_login: string; discord_user_id: string; callback: (success: SimpleResult) => void }
>();
}
async start() {
const { promise, resolver } = split_promise<void>();
this.bot.on("ready", () => resolver());
this.bot.login(this.token);
await promise;
await this.register_commands();
}
private async register_commands() {
const 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")
.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] },
);
}
}
private async handle_interaction(interaction: Interaction<CacheType>) {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName != "associate") return;
const email = interaction.options.getString("cri_email")!;
if (!email.endsWith("@epita.fr")) {
await interaction.reply(message_command_response_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<SimpleResult>();
this.assoc_channel.send({ cri_login, discord_user_id, callback: resolver });
await interaction.reply(message_command_response_sending_email(email));
const result = await promise;
if (result === true) interaction.editReply(message_command_response_success());
else interaction.editReply(message_command_response_error(result));
}
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}'.`);
}
async *receive_associations() {
while (true) yield await this.assoc_channel.receive();
}
}
function message_command_response_sending_email(email: string) {
const embed = new EmbedBuilder()
.setTitle("`🟡`| Verification")
.setDescription(`Sending a verification link to your email \`${email}\`.`);
return { embeds: [embed] };
}
function message_command_response_error(msg: string) {
const embed = new EmbedBuilder()
.setTitle("`❌`| Verification")
.setDescription(`An error occured\n\`${msg}\``);
return { embeds: [embed] };
}
function message_command_response_success() {
const embed = new EmbedBuilder()
.setTitle("`✅`| Verification")
.setDescription("Your email was associated with this account.");
return { embeds: [embed] };
}

43
src/cri.ts Normal file
View file

@ -0,0 +1,43 @@
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
export class CriApi {
token;
constructor(token: string) {
this.token = token;
}
async groups_of(login: string) {
const response = await fetch(`https://cri.epita.fr/api/v2/users/${login}/`, {
headers: {
accept: "application/json",
authorization: "Basic " + this.token,
},
});
const body = await response.json();
const group_parser = z.object({ slug: z.string() });
const parser = z.object({
primary_group: group_parser,
groups_history: z.array(z.object({
group: group_parser,
graduation_year: z.number().or(z.null()),
})),
current_groups: z.array(group_parser),
});
const parsed = parser.parse(body);
const result = new Set<string>();
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());
}
async user_exists(login: string) {
const response = await fetch(`https://cri.epita.fr/api/v2/users/${login}/`, {
headers: {
accept: "application/json",
authorization: "Basic " + this.token,
},
});
return response.status !== 404;
}
}

78
src/main.ts Executable file
View file

@ -0,0 +1,78 @@
#!/usr/bin/env -S deno run --allow-read --allow-write --allow-net --allow-env --unstable-kv
import { log_from, read_secrets, root_path, SimpleResult, wait } from "./utils.ts";
import { State } from "./state.ts";
import { RuleSet } from "./rules.ts";
import { EpitlsBot } from "./bot.ts";
import { CriApi } from "./cri.ts";
const log = (...args: unknown[]) => log_from(import.meta.url, ...args);
async function main() {
const secrets = await read_secrets(root_path() + "/secrets.json");
const rules = await RuleSet.from_file(root_path() + "/rules.json");
log("Loaded rules for", rules.rules.size, "groups.");
const state = await State.from_dir(root_path() + "/local");
log("Loaded state with", await state.users_count(), "users.");
const bot = new EpitlsBot(secrets.discord_bot_token);
await bot.start();
log(`Started bot '${bot.bot.user?.displayName}' .`);
const cri_api = new CriApi(secrets.cri_token);
const service = new Service(state, bot, cri_api, rules);
await service.serve();
}
class Service {
state;
bot;
cri_api;
rules;
constructor(state: State, bot: EpitlsBot, cri_api: CriApi, rules: RuleSet) {
this.state = state;
this.bot = bot;
this.cri_api = cri_api;
this.rules = rules;
}
async serve() {
await this.update_all_users_roles();
(async () => {
for await (const { discord_user_id, cri_login, callback } of this.bot.receive_associations()) {
this.association_procedure(discord_user_id, cri_login).then(callback);
}
})();
}
async update_all_users_roles() {
const promises = [] as Promise<void>[];
for await (const { cri_login, discord_user_id } of this.state.users()) {
promises.push(this.update_user_roles(cri_login, discord_user_id));
}
await Promise.all(promises);
}
async update_user_roles(cri_login: string, discord_user_id: string) {
const groups = await this.cri_api.groups_of(cri_login);
const roles = groups.map((group) => this.rules.roles_for_group(group)).flat();
for (const { guild_id, role_id } of roles) await this.bot.assign_role(discord_user_id, guild_id, role_id);
}
async association_procedure(discord_user_id: string, cri_login: string): Promise<SimpleResult> {
try {
await wait(1000);
if (!await this.cri_api.user_exists(cri_login)) return "No such login.";
this.state.set_user(discord_user_id, cri_login);
await this.update_user_roles(cri_login, discord_user_id);
} catch (error) {
console.error(error);
return `${error}`;
}
return true;
}
}
// bot.assign_role_to_member("358338548174159873", "871777993922588712", "1202346358867238952");
main();

29
src/rules.ts Normal file
View file

@ -0,0 +1,29 @@
type SerializedRule = { group_id: string; target_role: TargetRole };
export type TargetRole = { guild_id: string; role_id: string };
export class RuleSet {
rules;
constructor() {
this.rules = new Map<string, TargetRole[]>();
}
static async from_file(path: string) {
const result = new RuleSet();
const file_content = await Deno.readTextFile(path);
const parsed = JSON.parse(file_content) as SerializedRule[];
for (const { group_id, target_role } of parsed) result.append_rule(group_id, target_role);
return result;
}
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);
}
roles.push(target_role);
}
roles_for_group(group_id: string) {
return this.rules.get(group_id) ?? [];
}
}

51
src/state.ts Normal file
View file

@ -0,0 +1,51 @@
import { v1 as uuid } from "https://deno.land/std@0.213.0/uuid/mod.ts";
export type StoredUser = { discord_user_id: string; cri_login: string };
export class State {
kv;
constructor(kv: Deno.Kv) {
this.kv = kv;
}
public static async from_dir(local_path: string) {
await Deno.mkdir(local_path, { recursive: true });
const kv = await Deno.openKv(local_path + "/kv");
return new State(kv);
}
public async *users() {
const query = this.kv.list({ prefix: ["users/"] });
for await (const { value } of query) yield value as StoredUser;
}
public async users_count() {
let result = 0;
for await (const _ of this.users()) result += 1;
return result;
}
public async get_user(discord_user_id: string) {
const { value: user_uuid } = await this.kv.get(["users_by_discord_id/", discord_user_id]);
if (user_uuid === null) return undefined;
const { value: stored } = await this.kv.get(["users/", user_uuid as string]);
return stored as StoredUser;
}
public async set_user(discord_user_id: string, cri_login: string) {
await this.remove_user(discord_user_id);
await this.add_user(discord_user_id, cri_login);
}
private async add_user(discord_user_id: string, cri_login: string) {
const user_uuid = uuid.generate() as string;
const stored: StoredUser = { discord_user_id, cri_login };
await this.kv.set(["users/", user_uuid], stored);
await this.kv.set(["users_by_discord_id/", discord_user_id], user_uuid);
}
async remove_user(discord_user_id: string) {
const { value: user_uuid } = await this.kv.get(["users_by_discord_id/", discord_user_id]);
if (user_uuid === null) return undefined;
await this.kv.delete(["users/", user_uuid as string]);
}
}

59
src/utils.ts Normal file
View file

@ -0,0 +1,59 @@
import * as path from "https://deno.land/std@0.213.0/path/mod.ts";
import { z } from "https://deno.land/x/zod@v3.22.4/mod.ts";
export function root_path() {
const this_path = new URL(import.meta.url).pathname;
return path.resolve(this_path, "..", "..");
}
export function channel<T>() {
const inner = {
items: [] as T[],
awaiters: [] as ((item: T) => void)[],
};
function send(item: T) {
const [awaiter] = inner.awaiters.splice(0, 1);
if (awaiter !== undefined) awaiter(item);
else inner.items.push(item);
}
function receive() {
const [item] = inner.items.splice(0, 1);
if (item !== undefined) return resolves_to(item);
else return new Promise<T>((resolver) => inner.awaiters.push(resolver));
}
return { send, receive };
}
const resolves_to = <T>(item: T) => new Promise<T>((r) => r(item));
export function split_promise<T>() {
let resolver: null | ((item: T) => void) = null;
const promise = new Promise<T>((r) => resolver = r);
resolver = resolver as unknown as (item: T) => void;
return { promise, resolver };
}
export function log_from(url: string, ...args: unknown[]) {
const date = new Date().toLocaleString("fr-FR");
const file = path.basename(new URL(url).pathname);
console.log(`[${date}][epitls][${file}]`, ...args);
}
export async function read_secrets(path: string) {
const content = await Deno.readTextFile(path);
return z.object({
discord_bot_token: z.string(),
cri_token: z.string(),
}).parse(JSON.parse(content));
}
export type SimpleResult = true | string;
export async function wait(ms: number) {
const { promise, resolver } = split_promise<void>();
setTimeout(resolver, ms);
await promise;
}