From 1e2888a94f739b9f597a321b1e3f47c85638de4f Mon Sep 17 00:00:00 2001 From: JOLIMAITRE Matthieu Date: Mon, 29 Apr 2024 13:49:17 +0200 Subject: [PATCH] working state --- src/bot.ts | 175 +++++++++++++++++++++++++++++++++++---------- src/lib/storage.ts | 30 +++++--- 2 files changed, 158 insertions(+), 47 deletions(-) diff --git a/src/bot.ts b/src/bot.ts index fddc05d..58d1e86 100755 --- a/src/bot.ts +++ b/src/bot.ts @@ -14,6 +14,7 @@ import { import { Channel, channel, collect, days_to_ms, log_from, wait } from "./lib/utils.ts"; import { Storage } from "./lib/storage.ts"; +import { Devoir } from "./lib/storage.ts"; const log = log_from(import.meta); @@ -28,8 +29,6 @@ const subjects = new Set([ "Sûreté", ]); -const feed_channel_id = "871779571064778784"; - async function main() { const [token_file] = Deno.args; const token = (await Deno.readTextFile(token_file)).trim(); @@ -47,19 +46,22 @@ async function main() { await bot.login(token); log("Logged in as", bot.user?.username); - const feed_channel = await bot.channels.fetch(feed_channel_id); - assertExists(feed_channel); - assert(feed_channel instanceof TextChannel); - 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_loop(bot, storage, update_display); update_display.send(); - notification_loop(bot, storage, feed_channel); + notification_loop(bot, storage); +} + +async function fetch_feed_channel(bot: Client, feed: { channel_id: string }) { + const feed_channel = await bot.channels.fetch(feed.channel_id); + assertExists(feed_channel); + assert(feed_channel instanceof TextChannel); + return feed_channel; } function build_api_commands(subjects: Set) { @@ -123,7 +125,18 @@ function build_api_commands(subjects: Set) { .setRequired(false) ) ); - return [devoir_command]; + const adm_command = new SlashCommandBuilder().setName("adm") + .setDescription("Commandes d'administration.") + .addSubcommand((command) => + command.setName("ajouter-feed") + .setDescription("Ajoute un salon comme feed de notifications.") + .addChannelOption((option) => + option.setName("salon") + .setDescription("Salon du nouveau feed.") + .setRequired(true) + ) + ); + return [devoir_command, adm_command]; } async function handle_command( @@ -139,7 +152,7 @@ async function handle_command( 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 { id } = await storage.devoirs.add({ date, description, subject }); const embed = new EmbedBuilder() .setTitle("`🌟` Ajouté") .setDescription(`Nouveau devoir ajouté avec succès.`) @@ -150,7 +163,7 @@ async function handle_command( } if (subcommand === "retirer") { const id = interaction.options.getString("devoir", true); - await storage.devoirs.delete(id); + await storage.devoirs.delete({ id }); const embed = new EmbedBuilder() .setTitle("`🗑️` Retiré") .setDescription(`Devoir supprimé avec succès.`) @@ -162,11 +175,11 @@ async function handle_command( 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); + 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); + 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)); + 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.`) @@ -177,6 +190,25 @@ async function handle_command( } return log("Unknown devoir sub command", subcommand); } + if (interaction.commandName === "adm") { + const subcommand = interaction.options.getSubcommand(true); + if (subcommand === "ajouter-feed") { + const channel = interaction.options.getChannel("salon", true); + const is_text_channel = channel instanceof TextChannel; + if (!is_text_channel) { + await interaction.reply("Channel must be text."); + return; + } + const board_message = await channel.send("[board]"); + const feed_id = await storage.feeds.add({ + channel_id: channel.id, + board_message_id: board_message.id, + notification_ids: new Set(), + }); + await interaction.reply("Added feed " + feed_id.id); + } + return log("Unknown adm sub command", subcommand); + } log("Unknown command", interaction.commandName); } @@ -186,17 +218,17 @@ async function handle_autocomplete(interaction: AutocompleteInteraction, storage const subcommand = interaction.options.getSubcommand(true); if (subcommand === "retirer") { const devoirs = await collect(storage.devoirs.list()); - const mapped = devoirs.map(([id, value]) => ({ + const mapped = devoirs.map(([{ id }, value]) => ({ name: `[${value.subject}] ${value.description}`, - value: id as string, + value: id, })); return await interaction.respond(mapped); } if (subcommand === "éditer") { const devoirs = await collect(storage.devoirs.list()); - const mapped = devoirs.map(([id, value]) => ({ + const mapped = devoirs.map(([{ id }, value]) => ({ name: `[${value.subject}] ${value.description}`, - value: id as string, + value: id, })); return await interaction.respond(mapped); } @@ -205,44 +237,109 @@ async function handle_autocomplete(interaction: AutocompleteInteraction, storage log("Unknown command", interaction.commandName); } -async function update_loop(bot: Client, storage: Storage, update_display: Channel, feed_channel: TextChannel) { +async function update_loop(bot: Client, storage: Storage, update_display: Channel) { 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 }); + log("Updating board."); + for await (const [_, feed] of storage.feeds.list()) { + const feed_channel = await fetch_feed_channel(bot, feed); + const embed = new EmbedBuilder().setTitle("`📚` Devoirs") + .setFooter({ text: "Mise à jour " + Date.now() }); + const sorted_devoirs = (await (collect(storage.devoirs.list()))) + .map(([_, d]) => d).toSorted((a, b) => a.date > b.date ? 1 : -1); + for (const devoir of sorted_devoirs) { + embed.addFields({ name: format_devoir_title(devoir), value: devoir.description }); + } + const board_message = await feed_channel.messages.fetch(feed.board_message_id); + board_message.edit({ embeds: [embed], content: "" }); } - feed_channel.send({ embeds: [embed] }); } } const _1min = 60 * 1000; const _24h = 24 * 60 * _1min; -const _3d = 3 * _24h; +const _7d = 7 * _24h; -async function notification_loop(bot: Client, storage: Storage, feed_channel: TextChannel) { +function format_devoir_title(devoir: Devoir) { + const date = new Date(devoir.date); + return `${date.getDate()}/${date.getMonth() + 1} - ${devoir.subject}`; +} + +async function notification_loop(bot: Client, storage: Storage) { while (true) { - const devoirs_to_notify = new Set(); - const notification_threshold = _3d; + log("Updating notification."); + // get all devoirs to notify + const devoirs_to_notify = new Set(); + const notification_threshold = _7d; for await (const [devoir_id, devoir] of storage.devoirs.list()) { - if (Date.now() - devoir.date < notification_threshold) devoirs_to_notify.add(devoir_id); + const time_left = devoir.date - Date.now(); + if (time_left < 0) continue; // TODO : delete devoir. + if (time_left > notification_threshold) continue; + devoirs_to_notify.add(devoir_id.id); } + // delete all obsolete notifications for await (const [notification_id, notification] of storage.notifications.list()) { - if (!devoirs_to_notify.has(notification.devoir_id)) { - try { - const message = await feed_channel.messages.fetch(notification.message_id); - await message.delete(); - } catch (_) { /* . */ } + if (devoirs_to_notify.has(notification.devoir_id)) continue; + const feed = await storage.feeds.get({ id: notification.feed_id }); + if (feed === null) continue; + const feed_channel = await fetch_feed_channel(bot, feed); + try { + const message = await feed_channel.messages.fetch(notification.message_id); + await message.delete(); + } catch (_) { /* . */ } + await storage.notifications.delete(notification_id); + await storage.feeds.update({ id: notification.feed_id }, (f) => f.notification_ids.delete(notification_id.id)); + } + + // create missing messages. + for await (const [feed_id, feed] of storage.feeds.list()) { + const feed_channel = await fetch_feed_channel(bot, feed); + // find devoirs needing to create a notification for + const devoirs_to_notify_in_feed = new Set(devoirs_to_notify.values()); + for (const existing_notification_id of feed.notification_ids.values()) { + const notification = await storage.notifications.get({ id: existing_notification_id }); + assertExists(notification); + devoirs_to_notify_in_feed.delete(notification.devoir_id); + } + // create notifications + for (const devoir_id of devoirs_to_notify_in_feed.values()) { + const devoir = await storage.devoirs.get({ id: devoir_id }); + if (devoir === null) continue; + const embed = new EmbedBuilder() + .setTitle(format_devoir_title(devoir)) + .setDescription(devoir.description); + const message = await feed_channel.send({ embeds: [embed] }); + const notification_id = await storage.notifications.add({ + devoir_id: devoir_id, + message_id: message.id, + feed_id: feed_id.id, + }); + storage.feeds.update(feed_id, (f) => f.notification_ids.add(notification_id.id)); } } + + // for each notification, update. + for await (const [_, notification] of storage.notifications.list()) { + const devoir = await storage.devoirs.get({ id: notification.devoir_id }); + if (devoir === null) continue; + const feed = await storage.feeds.get({ id: notification.feed_id }); + if (feed === null) continue; + const feed_channel = await fetch_feed_channel(bot, feed); + const message = await feed_channel.messages.fetch(notification.message_id); + const ms_left = devoir.date - Date.now(); + const days_left = Math.floor(ms_left / _24h); + const embed = new EmbedBuilder() + .setTitle(`${format_devoir_title(devoir)} (${days_left} jours)`) + .setDescription(devoir.description) + .setColor(0x8888ff); + if (days_left <= 5) embed.setColor(0xffd726); + if (days_left <= 3) embed.setColor(0xff8b26); + if (days_left <= 1) embed.setColor(0xff2626); + await message.edit({ embeds: [embed], content: "" }); + } + await wait(_1min); } } diff --git a/src/lib/storage.ts b/src/lib/storage.ts index a42e976..5dba7ad 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -5,10 +5,13 @@ import { last, log_from } from "./utils.ts"; const log = log_from(import.meta); +export type Id = { id: string }; + export class Storage { db; devoirs; notifications; + feeds; constructor(db: Deno.Kv) { this.db = db; @@ -19,10 +22,17 @@ export class Storage { }); this.devoirs = new Manager(db, "devoir", devoir_parser); const notification_parser = z.object({ + feed_id: z.string(), devoir_id: z.string(), message_id: z.string(), }); this.notifications = new Manager(db, "notification", notification_parser); + const feed_parser = z.object({ + channel_id: z.string(), + board_message_id: z.string(), + notification_ids: z.set(z.string()), + }); + this.feeds = new Manager(db, "feed", feed_parser); } static async open(path: string) { @@ -34,9 +44,13 @@ export class Storage { async sanity() { await this.devoirs.sanity(); + await this.notifications.sanity(); + await this.feeds.sanity(); } } +export type Devoir = z.infer; + class Manager { db; label; @@ -51,10 +65,10 @@ class Manager { async add(value: T) { const id = `${Date.now()}${Math.random()}`; await this.db.set([this.label, id], value); - return id; + return { id } as Id; } - async get(id: string) { + async get({ id }: Id) { const entry = await this.db.get([this.label, id]); if (entry.value === null) return null; const parsed = this.parse(entry.value); @@ -70,18 +84,18 @@ class Manager { } } - async set(id: string, value: T) { + async set({ id }: Id, value: T) { await this.db.set([this.label, id], value); } - async update(id: string, operation: (item: T) => unknown) { - const value = await this.get(id); + async update({ id }: Id, operation: (item: T) => unknown) { + const value = await this.get({ id }); if (value === null) return; await operation(value); - await this.set(id, value); + await this.set({ id }, value); } - async delete(id: string) { + async delete({ id }: Id) { await this.db.delete([this.label, id]); } @@ -91,7 +105,7 @@ class Manager { assert(typeof id === "string"); const value = this.parse(entry.value); if (value === null) continue; - yield [id, value as T] as const; + yield [{ id } as Id, value as T] as const; } }