Init.
This commit is contained in:
commit
c872cad278
11 changed files with 1052 additions and 0 deletions
111
src/bot.ts
Normal file
111
src/bot.ts
Normal 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
43
src/cri.ts
Normal 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
78
src/main.ts
Executable 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
29
src/rules.ts
Normal 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
51
src/state.ts
Normal 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
59
src/utils.ts
Normal 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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue