375 lines
13 KiB
TypeScript
375 lines
13 KiB
TypeScript
import {
|
|
CacheType,
|
|
ChatInputCommandInteraction,
|
|
Client,
|
|
EmbedBuilder,
|
|
GatewayIntentBits,
|
|
Guild,
|
|
Interaction,
|
|
REST,
|
|
Routes,
|
|
SlashCommandBooleanOption,
|
|
SlashCommandBuilder,
|
|
SlashCommandRoleOption,
|
|
SlashCommandStringOption,
|
|
SlashCommandSubcommandBuilder,
|
|
SlashCommandUserOption,
|
|
} from "npm:discord.js@14.14.1";
|
|
import { RuleSet } from "./rules.ts";
|
|
import { channel, log_from, SimpleResult, split_promise, try_ } from "./utils.ts";
|
|
const log = log_from(import.meta.url);
|
|
|
|
/**
|
|
* Wraps a discord bot and implements required actions.
|
|
*/
|
|
export class EpitlsBot {
|
|
private bot;
|
|
private token;
|
|
private rest;
|
|
private rules;
|
|
private assoc_channel;
|
|
|
|
public constructor(bot_token: string, rules: RuleSet) {
|
|
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<Association | Dissociation>();
|
|
}
|
|
|
|
/**
|
|
* Connects to discord API server and registers slash commands.\
|
|
* Needs to be run after construction as it is an asynchronous operation.
|
|
*/
|
|
public async start() {
|
|
const { promise, resolver } = split_promise<void>();
|
|
this.bot.on("ready", () => resolver());
|
|
this.bot.login(this.token);
|
|
await promise;
|
|
await this.register_commands();
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
log(`Assigning to user '${member.displayName}' role '${role.name}'.`);
|
|
await try_(
|
|
() => member.roles.add(role_id),
|
|
() => log(`FAILED Assignation 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_(() => user.setNickname(generated_name), () => log(`FAILED Setting name of '${name}'.`));
|
|
}
|
|
}
|
|
|
|
private async try_fetch_member(guild: Guild, user_id: string) {
|
|
try {
|
|
return await guild.members.fetch({ user: user_id });
|
|
} catch (_) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pull-style API to wait for and receive account association requests.
|
|
*/
|
|
public async *receive_associations() {
|
|
while (true) yield await this.assoc_channel.receive();
|
|
}
|
|
|
|
/**
|
|
* Connected bot name accessor.
|
|
*/
|
|
public bot_name() {
|
|
return this.bot.user?.displayName;
|
|
}
|
|
|
|
public async get_username(user_id: string) {
|
|
try {
|
|
const user = await this.bot.users.fetch(user_id);
|
|
return user.displayName;
|
|
} catch (_) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private async register_commands() {
|
|
const assoc_cmd = new SlashCommandBuilder()
|
|
.setName("associate")
|
|
.setDescription("Associes un utilisateur cri avec votre compte discord.")
|
|
.addStringOption(
|
|
new SlashCommandStringOption()
|
|
.setName("cri_email")
|
|
.setDescription("par exemple, claude.nougaro@epita.fr")
|
|
.setRequired(true),
|
|
).toJSON();
|
|
|
|
const ADMIN_PERMISSIONS = 0x0000000000000008n;
|
|
const dissoc_cmd = new SlashCommandBuilder()
|
|
.setName("dissociate")
|
|
.setDescription("Dissocies un utilisateur cri de votre compte discord.")
|
|
.setDefaultMemberPermissions(ADMIN_PERMISSIONS)
|
|
.addUserOption(
|
|
new SlashCommandUserOption()
|
|
.setName("user")
|
|
.setRequired(true),
|
|
).toJSON();
|
|
|
|
const rule_cmd = new SlashCommandBuilder()
|
|
.setName("rule")
|
|
.setDescription("Gestion des règles d'associations groupes cri / rôles.")
|
|
.setDefaultMemberPermissions(ADMIN_PERMISSIONS)
|
|
.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),
|
|
)
|
|
.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(
|
|
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, dissoc_cmd, rule_cmd] },
|
|
);
|
|
}
|
|
}
|
|
|
|
private async handle_interaction(interaction: Interaction<CacheType>) {
|
|
if (!interaction.isChatInputCommand()) return;
|
|
if (interaction.commandName === "associate") await this.handle_command_associate(interaction);
|
|
if (interaction.commandName === "dissociate") await this.handle_command_dissociate(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<CacheType>) {
|
|
const email = interaction.options.getString("cri_email")!;
|
|
if (!email.endsWith("@epita.fr")) {
|
|
await interaction.reply(message_command_response_assoc_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({ kind: "associate", cri_login, discord_user_id, callback: resolver });
|
|
await interaction.reply(message_command_response_assoc_pending(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_dissociate(interaction: ChatInputCommandInteraction<CacheType>) {
|
|
const user = interaction.options.getUser("user");
|
|
if (user === null) throw new Error("Unreachable.");
|
|
const discord_user_id = user.id;
|
|
const { promise, resolver } = split_promise<SimpleResult>();
|
|
this.assoc_channel.send({ kind: "dissociate", discord_user_id, callback: resolver });
|
|
await promise;
|
|
await interaction.reply(message_command_response_dissoc_success());
|
|
}
|
|
|
|
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, include_historical } 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, include_historical });
|
|
}
|
|
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);
|
|
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 }, 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<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 }, include_historical } of removed) {
|
|
const role = await interaction.guild!.roles.fetch(role_id);
|
|
const role_name = role?.name ?? "<DELETED>";
|
|
rules.push({ group_id, role_name, include_historical });
|
|
}
|
|
await interaction.reply(message_command_response_rule_remove_success(rules));
|
|
}
|
|
}
|
|
|
|
export type Association = {
|
|
kind: "associate";
|
|
cri_login: string;
|
|
discord_user_id: string;
|
|
callback: (success: SimpleResult) => void;
|
|
};
|
|
|
|
export type Dissociation = {
|
|
kind: "dissociate";
|
|
discord_user_id: string;
|
|
callback: (success: SimpleResult) => void;
|
|
};
|
|
|
|
function message_command_response_assoc_pending(email: string) {
|
|
const embed = new EmbedBuilder()
|
|
.setTitle("`🟡`| Association")
|
|
.setDescription(`
|
|
Un lien de vérification a été envoyé par e-mail à \`${email}\`.
|
|
Le lien expirera dans 5 minutes.
|
|
**Il pourraît être arrivé dans \`Courrier indésirable\`.**
|
|
`.trim());
|
|
return { embeds: [embed] };
|
|
}
|
|
|
|
function message_command_response_assoc_error(msg: string) {
|
|
const embed = new EmbedBuilder()
|
|
.setTitle("`❌`| Association")
|
|
.setDescription(`Échec de l'association :\n\`${msg}\``);
|
|
return { embeds: [embed] };
|
|
}
|
|
|
|
function message_command_response_assoc_success() {
|
|
const embed = new EmbedBuilder()
|
|
.setTitle("`✅`| Association")
|
|
.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; include_historical: boolean };
|
|
function message_command_response_rule_list_success(rules: GuildRule[]) {
|
|
const embed = new EmbedBuilder()
|
|
.setTitle("`📚️`| Liste des règles")
|
|
.setDescription(`Règles d'associations groupe / rôle.
|
|
${rules.map(display_rule).join("\n")}
|
|
`);
|
|
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 :
|
|
${display_rule(rule)}
|
|
`);
|
|
return { embeds: [embed] };
|
|
}
|
|
|
|
function message_command_response_rule_remove_success(rules: GuildRule[]) {
|
|
const embed = new EmbedBuilder()
|
|
.setTitle("`✅`| Règle ajouté")
|
|
.setDescription(`Règle d'association retirées :
|
|
${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 message_command_response_dissoc_success() {
|
|
const embed = new EmbedBuilder()
|
|
.setTitle("`✅`| Dissociation")
|
|
.setDescription("Votre compte Discord a bien été dissocié de votre compte CRI.");
|
|
return { embeds: [embed] };
|
|
}
|
|
|
|
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}\``;
|
|
}
|