init
This commit is contained in:
commit
5a40f83a74
11 changed files with 598 additions and 0 deletions
247
src/bot.ts
Executable file
247
src/bot.ts
Executable file
|
@ -0,0 +1,247 @@
|
|||
#!/bin/env -S deno run -A --unstable-kv
|
||||
|
||||
import {
|
||||
AutocompleteInteraction,
|
||||
ChatInputCommandInteraction,
|
||||
Client,
|
||||
EmbedBuilder,
|
||||
SlashCommandBuilder,
|
||||
TextChannel,
|
||||
} from "npm:discord.js";
|
||||
|
||||
import { Channel, channel, collect, days_to_ms, log_from, wait } from "./lib/utils.ts";
|
||||
import { Storage } from "./lib/storage.ts";
|
||||
|
||||
const log = log_from(import.meta);
|
||||
|
||||
const subjects = new Set([
|
||||
"Conception",
|
||||
"Hardware",
|
||||
"Infrastructure Cloud",
|
||||
"Intelligence Artificielle",
|
||||
"Robotique",
|
||||
"Sécurité",
|
||||
"SEPT",
|
||||
"Sûreté",
|
||||
]);
|
||||
|
||||
async function main() {
|
||||
const [token] = Deno.args;
|
||||
|
||||
const storage = await Storage.open("./local/db");
|
||||
const bot = new Client({ intents: ["GuildMessages", "Guilds"] });
|
||||
const feed_channel = await bot.channels.fetch("871779571064778784");
|
||||
assertExists(feed_channel);
|
||||
assert(feed_channel instanceof TextChannel);
|
||||
const update_display = channel();
|
||||
|
||||
bot.on("interactionCreate", async (interaction) => {
|
||||
if (interaction.isChatInputCommand()) return await handle_command(interaction, storage, update_display);
|
||||
if (interaction.isAutocomplete()) return await handle_autocomplete(interaction, storage);
|
||||
});
|
||||
|
||||
log("Logging in ...");
|
||||
await bot.login(token);
|
||||
log("Logged in as", bot.user?.username);
|
||||
const commands = build_api_commands(subjects);
|
||||
await bot.application?.commands.set([]);
|
||||
await bot.application?.commands.set(commands, "871777993922588712");
|
||||
log("Registered", commands.length, "commands.");
|
||||
|
||||
update_loop(bot, storage, update_display, feed_channel);
|
||||
update_display.send();
|
||||
|
||||
notification_loop(bot, storage, feed_channel);
|
||||
}
|
||||
|
||||
function build_api_commands(subjects: Set<string>) {
|
||||
const subjects_as_choices = [...subjects.values()].map((name) => ({ name, value: name }));
|
||||
const devoir_command = new SlashCommandBuilder()
|
||||
.setName("devoir")
|
||||
.setDescription("Manipuler les devoirs à effectuer.")
|
||||
.addSubcommand((command) =>
|
||||
command.setName("ajouter")
|
||||
.setDescription("Ajouter un nouveau devoir à effectuer.")
|
||||
.addStringOption((option) =>
|
||||
option.setName("matière")
|
||||
.setDescription("Matière du devoir à effectuer.")
|
||||
.addChoices(...subjects_as_choices)
|
||||
.setRequired(true)
|
||||
)
|
||||
.addNumberOption((option) =>
|
||||
option.setName("jours")
|
||||
.setMinValue(0)
|
||||
.setDescription("Nombre de jours avant la date du rendu.")
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option.setName("description")
|
||||
.setDescription("Description du devoir à effectuer.")
|
||||
.setRequired(true)
|
||||
)
|
||||
).addSubcommand((command) =>
|
||||
command.setName("retirer")
|
||||
.setDescription("Retirer un devoir.")
|
||||
.addStringOption((option) =>
|
||||
option.setName("devoir")
|
||||
.setDescription("Devoir à retirer.")
|
||||
.setAutocomplete(true)
|
||||
.setRequired(true)
|
||||
)
|
||||
).addSubcommand((command) =>
|
||||
command.setName("éditer")
|
||||
.setDescription("Éditer un devoir.")
|
||||
.addStringOption((option) =>
|
||||
option.setName("devoir")
|
||||
.setDescription("Devoir à éditer.")
|
||||
.setAutocomplete(true)
|
||||
.setRequired(true)
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option.setName("matière")
|
||||
.setDescription("Nouvelle matière.")
|
||||
.addChoices(...subjects_as_choices)
|
||||
.setRequired(false)
|
||||
)
|
||||
.addNumberOption((option) =>
|
||||
option.setName("jours")
|
||||
.setMinValue(0)
|
||||
.setDescription("Nouveau nombre de jours avant la date du rendu.")
|
||||
.setRequired(false)
|
||||
)
|
||||
.addStringOption((option) =>
|
||||
option.setName("description")
|
||||
.setDescription("Nouvelle description.")
|
||||
.setRequired(false)
|
||||
)
|
||||
);
|
||||
return [devoir_command];
|
||||
}
|
||||
|
||||
async function handle_command(
|
||||
interaction: ChatInputCommandInteraction,
|
||||
storage: Storage,
|
||||
update_display: Channel,
|
||||
) {
|
||||
log("Received command", interaction.commandName);
|
||||
if (interaction.commandName === "devoir") {
|
||||
const subcommand = interaction.options.getSubcommand(true);
|
||||
if (subcommand === "ajouter") {
|
||||
const subject = interaction.options.getString("matière", true);
|
||||
const days = interaction.options.getNumber("jours", true);
|
||||
const description = interaction.options.getString("description", true);
|
||||
const date = Date.now() + days_to_ms(days);
|
||||
const id = await storage.devoirs.add({ date, description, subject });
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("`🌟` Ajouté")
|
||||
.setDescription(`Nouveau devoir ajouté avec succès.`)
|
||||
.setFooter({ text: `(devoir id:${id})` });
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
update_display.send();
|
||||
return;
|
||||
}
|
||||
if (subcommand === "retirer") {
|
||||
const id = interaction.options.getString("devoir", true);
|
||||
await storage.devoirs.delete(id);
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("`🗑️` Retiré")
|
||||
.setDescription(`Devoir supprimé avec succès.`)
|
||||
.setFooter({ text: `(devoir id:${id})` });
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
update_display.send();
|
||||
return;
|
||||
}
|
||||
if (subcommand === "éditer") {
|
||||
const id = interaction.options.getString("devoir", true);
|
||||
const subject = interaction.options.getString("matière", false);
|
||||
if (subject !== null) storage.devoirs.update(id, (d) => d.subject = subject);
|
||||
const description = interaction.options.getString("description", false);
|
||||
if (description !== null) storage.devoirs.update(id, (d) => d.description = description);
|
||||
const days = interaction.options.getNumber("jours", false);
|
||||
if (days !== null) storage.devoirs.update(id, (d) => d.date = Date.now() + days_to_ms(days));
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle("`🔧` Édité")
|
||||
.setDescription(`Devoir mis à jour avec succès.`)
|
||||
.setFooter({ text: `(devoir id:${id})` });
|
||||
await interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
update_display.send();
|
||||
return;
|
||||
}
|
||||
return log("Unknown devoir sub command", subcommand);
|
||||
}
|
||||
log("Unknown command", interaction.commandName);
|
||||
}
|
||||
|
||||
async function handle_autocomplete(interaction: AutocompleteInteraction, storage: Storage) {
|
||||
log("Auto completing.");
|
||||
if (interaction.commandName === "devoir") {
|
||||
const subcommand = interaction.options.getSubcommand(true);
|
||||
if (subcommand === "retirer") {
|
||||
const devoirs = await collect(storage.devoirs.list());
|
||||
const mapped = devoirs.map(([id, value]) => ({
|
||||
name: `[${value.subject}] ${value.description}`,
|
||||
value: id as string,
|
||||
}));
|
||||
return await interaction.respond(mapped);
|
||||
}
|
||||
if (subcommand === "éditer") {
|
||||
const devoirs = await collect(storage.devoirs.list());
|
||||
const mapped = devoirs.map(([id, value]) => ({
|
||||
name: `[${value.subject}] ${value.description}`,
|
||||
value: id as string,
|
||||
}));
|
||||
return await interaction.respond(mapped);
|
||||
}
|
||||
return log("Unknown devoir sub command", subcommand);
|
||||
}
|
||||
log("Unknown command", interaction.commandName);
|
||||
}
|
||||
|
||||
import { assertExists } from "https://deno.land/std@0.223.0/assert/assert_exists.ts";
|
||||
import { assert } from "https://deno.land/std@0.223.0/assert/assert.ts";
|
||||
|
||||
async function update_loop(bot: Client, storage: Storage, update_display: Channel, feed_channel: TextChannel) {
|
||||
log("Waiting for updates.");
|
||||
while (true) {
|
||||
const _trigger = await update_display.receive();
|
||||
log("Updating.");
|
||||
const embed = new EmbedBuilder().setTitle("`📚` Devoirs");
|
||||
const sorted = (await (collect(storage.devoirs.list())))
|
||||
.map(([_, d]) => d)
|
||||
.toSorted((a, b) => a.date > b.date ? 1 : -1);
|
||||
for (const devoir of sorted) {
|
||||
const date = new Date(devoir.date);
|
||||
const description = `${date.getDate()}/${date.getMonth() + 1} - ${devoir.subject}`;
|
||||
embed.addFields({ name: description, value: devoir.description });
|
||||
}
|
||||
feed_channel.send({ embeds: [embed] });
|
||||
}
|
||||
}
|
||||
|
||||
const _1min = 60 * 1000;
|
||||
const _24h = 24 * 60 * _1min;
|
||||
|
||||
async function notification_loop(bot: Client, storage: Storage, feed_channel: TextChannel) {
|
||||
while (true) {
|
||||
const devoirs_to_notify = new Set();
|
||||
const notification_threshold = _24h;
|
||||
for await (const [devoir_id, devoir] of storage.devoirs.list()) {
|
||||
if (Date.now() - devoir.date < notification_threshold) devoirs_to_notify.add(devoir_id);
|
||||
}
|
||||
|
||||
for await (const [notification_id, notification] of storage.notifications.list()) {
|
||||
if (!devoirs_to_notify.has(notification.devoir_id)) {
|
||||
const channel = await bot.channels.fetch("e");
|
||||
assert(channel !== null);
|
||||
assert(channel.isTextBased());
|
||||
try {
|
||||
const message = await feed_channel.messages.fetch(notification.message_id);
|
||||
await message.delete();
|
||||
} catch (_) { /* . */ }
|
||||
}
|
||||
}
|
||||
await wait(_1min);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) await main();
|
101
src/lib/storage.ts
Normal file
101
src/lib/storage.ts
Normal file
|
@ -0,0 +1,101 @@
|
|||
import { assert } from "https://deno.land/std@0.223.0/assert/assert.ts";
|
||||
|
||||
import { z } from "https://deno.land/x/zod@v3.23.4/mod.ts";
|
||||
import { last, log_from } from "./utils.ts";
|
||||
|
||||
const log = log_from(import.meta);
|
||||
|
||||
export class Storage {
|
||||
db;
|
||||
devoirs;
|
||||
notifications;
|
||||
|
||||
constructor(db: Deno.Kv) {
|
||||
this.db = db;
|
||||
const devoir_parser = z.object({
|
||||
subject: z.string(),
|
||||
date: z.number(),
|
||||
description: z.string(),
|
||||
});
|
||||
this.devoirs = new Manager(db, "devoir", devoir_parser);
|
||||
const notification_parser = z.object({
|
||||
devoir_id: z.string(),
|
||||
message_id: z.string(),
|
||||
});
|
||||
this.notifications = new Manager(db, "notification", notification_parser);
|
||||
}
|
||||
|
||||
static async open(path: string) {
|
||||
const kv = await Deno.openKv(path);
|
||||
const result = new Storage(kv);
|
||||
await result.sanity();
|
||||
return result;
|
||||
}
|
||||
|
||||
async sanity() {
|
||||
await this.devoirs.sanity();
|
||||
}
|
||||
}
|
||||
|
||||
class Manager<T> {
|
||||
db;
|
||||
label;
|
||||
parser;
|
||||
|
||||
constructor(db: Deno.Kv, label: string, parser: z.ZodType<T>) {
|
||||
this.db = db;
|
||||
this.label = label;
|
||||
this.parser = parser;
|
||||
}
|
||||
|
||||
async add(value: T) {
|
||||
const id = `${Date.now()}${Math.random()}`;
|
||||
await this.db.set([this.label, id], value);
|
||||
return id;
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
const entry = await this.db.get([this.label, id]);
|
||||
if (entry.value === null) return null;
|
||||
const parsed = this.parse(entry.value);
|
||||
if (parsed === null) log(`Could not parse`, this.label, id, entry);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
parse(value: unknown) {
|
||||
try {
|
||||
return this.parser.parse(value);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async set(id: string, value: T) {
|
||||
await this.db.set([this.label, id], value);
|
||||
}
|
||||
|
||||
async update(id: string, operation: (item: T) => unknown) {
|
||||
const value = await this.get(id);
|
||||
if (value === null) return;
|
||||
await operation(value);
|
||||
await this.set(id, value);
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
await this.db.delete([this.label, id]);
|
||||
}
|
||||
|
||||
async *list() {
|
||||
for await (const entry of this.db.list({ prefix: [this.label] })) {
|
||||
const id = last(entry.key);
|
||||
assert(typeof id === "string");
|
||||
const value = this.parse(entry.value);
|
||||
if (value === null) continue;
|
||||
yield [id, value as T] as const;
|
||||
}
|
||||
}
|
||||
|
||||
async sanity() {
|
||||
for await (const _ of this.list()) _;
|
||||
}
|
||||
}
|
49
src/lib/utils.ts
Normal file
49
src/lib/utils.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { basename } from "https://deno.land/std@0.223.0/path/basename.ts";
|
||||
|
||||
export function log_from(meta: ImportMeta) {
|
||||
const url = new URL(meta.url);
|
||||
const file = basename(url.pathname);
|
||||
return (...args: unknown[]) => console.log(`[${file}]`, ...args);
|
||||
}
|
||||
|
||||
export function last<T>(items: Iterable<T>) {
|
||||
let result = null as T | null;
|
||||
for (const item of items) result = item;
|
||||
return result;
|
||||
}
|
||||
|
||||
export function days_to_ms(days: number) {
|
||||
const ms_per_day = 24 * 60 * 60 * 1000;
|
||||
return days * ms_per_day;
|
||||
}
|
||||
|
||||
export async function collect<T>(iterator: AsyncIterable<T>) {
|
||||
const collected = [] as T[];
|
||||
for await (const item of iterator) collected.push(item);
|
||||
return collected;
|
||||
}
|
||||
|
||||
export type Channel<T = void> = ReturnType<typeof channel<T>>;
|
||||
export function channel<T = void>() {
|
||||
const queue = [] as T[];
|
||||
let resolve_next = null as null | ((value: T) => void);
|
||||
|
||||
async function receive() {
|
||||
if (resolve_next !== null) throw new Error("Receiving twice concurently.");
|
||||
if (queue.length > 0) return queue.splice(0, 1)[0];
|
||||
const result = await new Promise<T>((resolver) => resolve_next = resolver);
|
||||
resolve_next = null;
|
||||
return result;
|
||||
}
|
||||
|
||||
function send(item: T) {
|
||||
if (resolve_next === null) queue.push(item);
|
||||
else resolve_next(item);
|
||||
}
|
||||
|
||||
return { send, receive };
|
||||
}
|
||||
|
||||
export async function wait(ms: number) {
|
||||
await new Promise((resolver) => setTimeout(resolver, ms));
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue