working state

This commit is contained in:
JOLIMAITRE Matthieu 2024-04-29 13:49:17 +02:00
parent 05654189c9
commit 1e2888a94f
2 changed files with 158 additions and 47 deletions

View file

@ -14,6 +14,7 @@ import {
import { Channel, channel, collect, days_to_ms, log_from, wait } from "./lib/utils.ts"; import { Channel, channel, collect, days_to_ms, log_from, wait } from "./lib/utils.ts";
import { Storage } from "./lib/storage.ts"; import { Storage } from "./lib/storage.ts";
import { Devoir } from "./lib/storage.ts";
const log = log_from(import.meta); const log = log_from(import.meta);
@ -28,8 +29,6 @@ const subjects = new Set([
"Sûreté", "Sûreté",
]); ]);
const feed_channel_id = "871779571064778784";
async function main() { async function main() {
const [token_file] = Deno.args; const [token_file] = Deno.args;
const token = (await Deno.readTextFile(token_file)).trim(); const token = (await Deno.readTextFile(token_file)).trim();
@ -47,19 +46,22 @@ async function main() {
await bot.login(token); await bot.login(token);
log("Logged in as", bot.user?.username); 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); const commands = build_api_commands(subjects);
await bot.application?.commands.set([]); await bot.application?.commands.set([]);
await bot.application?.commands.set(commands, "871777993922588712"); await bot.application?.commands.set(commands, "871777993922588712");
log("Registered", commands.length, "commands."); log("Registered", commands.length, "commands.");
update_loop(bot, storage, update_display, feed_channel); update_loop(bot, storage, update_display);
update_display.send(); update_display.send();
notification_loop(bot, storage, feed_channel); notification_loop(bot, storage);
}
async function fetch_feed_channel(bot: Client<boolean>, 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<string>) { function build_api_commands(subjects: Set<string>) {
@ -123,7 +125,18 @@ function build_api_commands(subjects: Set<string>) {
.setRequired(false) .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( async function handle_command(
@ -139,7 +152,7 @@ async function handle_command(
const days = interaction.options.getNumber("jours", true); const days = interaction.options.getNumber("jours", true);
const description = interaction.options.getString("description", true); const description = interaction.options.getString("description", true);
const date = Date.now() + days_to_ms(days); 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() const embed = new EmbedBuilder()
.setTitle("`🌟` Ajouté") .setTitle("`🌟` Ajouté")
.setDescription(`Nouveau devoir ajouté avec succès.`) .setDescription(`Nouveau devoir ajouté avec succès.`)
@ -150,7 +163,7 @@ async function handle_command(
} }
if (subcommand === "retirer") { if (subcommand === "retirer") {
const id = interaction.options.getString("devoir", true); const id = interaction.options.getString("devoir", true);
await storage.devoirs.delete(id); await storage.devoirs.delete({ id });
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setTitle("`🗑️` Retiré") .setTitle("`🗑️` Retiré")
.setDescription(`Devoir supprimé avec succès.`) .setDescription(`Devoir supprimé avec succès.`)
@ -162,11 +175,11 @@ async function handle_command(
if (subcommand === "éditer") { if (subcommand === "éditer") {
const id = interaction.options.getString("devoir", true); const id = interaction.options.getString("devoir", true);
const subject = interaction.options.getString("matière", false); 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); 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); 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() const embed = new EmbedBuilder()
.setTitle("`🔧` Édité") .setTitle("`🔧` Édité")
.setDescription(`Devoir mis à jour avec succès.`) .setDescription(`Devoir mis à jour avec succès.`)
@ -177,6 +190,25 @@ async function handle_command(
} }
return log("Unknown devoir sub command", subcommand); 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); log("Unknown command", interaction.commandName);
} }
@ -186,17 +218,17 @@ async function handle_autocomplete(interaction: AutocompleteInteraction, storage
const subcommand = interaction.options.getSubcommand(true); const subcommand = interaction.options.getSubcommand(true);
if (subcommand === "retirer") { if (subcommand === "retirer") {
const devoirs = await collect(storage.devoirs.list()); const devoirs = await collect(storage.devoirs.list());
const mapped = devoirs.map(([id, value]) => ({ const mapped = devoirs.map(([{ id }, value]) => ({
name: `[${value.subject}] ${value.description}`, name: `[${value.subject}] ${value.description}`,
value: id as string, value: id,
})); }));
return await interaction.respond(mapped); return await interaction.respond(mapped);
} }
if (subcommand === "éditer") { if (subcommand === "éditer") {
const devoirs = await collect(storage.devoirs.list()); const devoirs = await collect(storage.devoirs.list());
const mapped = devoirs.map(([id, value]) => ({ const mapped = devoirs.map(([{ id }, value]) => ({
name: `[${value.subject}] ${value.description}`, name: `[${value.subject}] ${value.description}`,
value: id as string, value: id,
})); }));
return await interaction.respond(mapped); return await interaction.respond(mapped);
} }
@ -205,44 +237,109 @@ async function handle_autocomplete(interaction: AutocompleteInteraction, storage
log("Unknown command", interaction.commandName); 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."); log("Waiting for updates.");
while (true) { while (true) {
const _trigger = await update_display.receive(); const _trigger = await update_display.receive();
log("Updating."); log("Updating board.");
const embed = new EmbedBuilder().setTitle("`📚` Devoirs"); for await (const [_, feed] of storage.feeds.list()) {
const sorted = (await (collect(storage.devoirs.list()))) const feed_channel = await fetch_feed_channel(bot, feed);
.map(([_, d]) => d) const embed = new EmbedBuilder().setTitle("`📚` Devoirs")
.toSorted((a, b) => a.date > b.date ? 1 : -1); .setFooter({ text: "Mise à jour " + Date.now() });
for (const devoir of sorted) { const sorted_devoirs = (await (collect(storage.devoirs.list())))
const date = new Date(devoir.date); .map(([_, d]) => d).toSorted((a, b) => a.date > b.date ? 1 : -1);
const description = `${date.getDate()}/${date.getMonth() + 1} - ${devoir.subject}`; for (const devoir of sorted_devoirs) {
embed.addFields({ name: description, value: devoir.description }); 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 _1min = 60 * 1000;
const _24h = 24 * 60 * _1min; 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) { while (true) {
const devoirs_to_notify = new Set(); log("Updating notification.");
const notification_threshold = _3d; // get all devoirs to notify
const devoirs_to_notify = new Set<string>();
const notification_threshold = _7d;
for await (const [devoir_id, devoir] of storage.devoirs.list()) { 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()) { for await (const [notification_id, notification] of storage.notifications.list()) {
if (!devoirs_to_notify.has(notification.devoir_id)) { if (devoirs_to_notify.has(notification.devoir_id)) continue;
try { const feed = await storage.feeds.get({ id: notification.feed_id });
const message = await feed_channel.messages.fetch(notification.message_id); if (feed === null) continue;
await message.delete(); const feed_channel = await fetch_feed_channel(bot, feed);
} catch (_) { /* . */ } 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); await wait(_1min);
} }
} }

View file

@ -5,10 +5,13 @@ import { last, log_from } from "./utils.ts";
const log = log_from(import.meta); const log = log_from(import.meta);
export type Id<T = unknown> = { id: string };
export class Storage { export class Storage {
db; db;
devoirs; devoirs;
notifications; notifications;
feeds;
constructor(db: Deno.Kv) { constructor(db: Deno.Kv) {
this.db = db; this.db = db;
@ -19,10 +22,17 @@ export class Storage {
}); });
this.devoirs = new Manager(db, "devoir", devoir_parser); this.devoirs = new Manager(db, "devoir", devoir_parser);
const notification_parser = z.object({ const notification_parser = z.object({
feed_id: z.string(),
devoir_id: z.string(), devoir_id: z.string(),
message_id: z.string(), message_id: z.string(),
}); });
this.notifications = new Manager(db, "notification", notification_parser); 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) { static async open(path: string) {
@ -34,9 +44,13 @@ export class Storage {
async sanity() { async sanity() {
await this.devoirs.sanity(); await this.devoirs.sanity();
await this.notifications.sanity();
await this.feeds.sanity();
} }
} }
export type Devoir = z.infer<Storage["devoirs"]["parser"]>;
class Manager<T> { class Manager<T> {
db; db;
label; label;
@ -51,10 +65,10 @@ class Manager<T> {
async add(value: T) { async add(value: T) {
const id = `${Date.now()}${Math.random()}`; const id = `${Date.now()}${Math.random()}`;
await this.db.set([this.label, id], value); await this.db.set([this.label, id], value);
return id; return { id } as Id<T>;
} }
async get(id: string) { async get({ id }: Id<T>) {
const entry = await this.db.get([this.label, id]); const entry = await this.db.get([this.label, id]);
if (entry.value === null) return null; if (entry.value === null) return null;
const parsed = this.parse(entry.value); const parsed = this.parse(entry.value);
@ -70,18 +84,18 @@ class Manager<T> {
} }
} }
async set(id: string, value: T) { async set({ id }: Id<T>, value: T) {
await this.db.set([this.label, id], value); await this.db.set([this.label, id], value);
} }
async update(id: string, operation: (item: T) => unknown) { async update({ id }: Id<T>, operation: (item: T) => unknown) {
const value = await this.get(id); const value = await this.get({ id });
if (value === null) return; if (value === null) return;
await operation(value); await operation(value);
await this.set(id, value); await this.set({ id }, value);
} }
async delete(id: string) { async delete({ id }: Id<T>) {
await this.db.delete([this.label, id]); await this.db.delete([this.label, id]);
} }
@ -91,7 +105,7 @@ class Manager<T> {
assert(typeof id === "string"); assert(typeof id === "string");
const value = this.parse(entry.value); const value = this.parse(entry.value);
if (value === null) continue; if (value === null) continue;
yield [id, value as T] as const; yield [{ id } as Id<T>, value as T] as const;
} }
} }