forked from epita/ruche-manager
working state
This commit is contained in:
parent
05654189c9
commit
1e2888a94f
2 changed files with 158 additions and 47 deletions
175
src/bot.ts
175
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<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>) {
|
||||
|
@ -123,7 +125,18 @@ function build_api_commands(subjects: Set<string>) {
|
|||
.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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,10 +5,13 @@ import { last, log_from } from "./utils.ts";
|
|||
|
||||
const log = log_from(import.meta);
|
||||
|
||||
export type Id<T = unknown> = { 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<Storage["devoirs"]["parser"]>;
|
||||
|
||||
class Manager<T> {
|
||||
db;
|
||||
label;
|
||||
|
@ -51,10 +65,10 @@ class Manager<T> {
|
|||
async add(value: T) {
|
||||
const id = `${Date.now()}${Math.random()}`;
|
||||
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]);
|
||||
if (entry.value === null) return null;
|
||||
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);
|
||||
}
|
||||
|
||||
async update(id: string, operation: (item: T) => unknown) {
|
||||
const value = await this.get(id);
|
||||
async update({ id }: Id<T>, 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<T>) {
|
||||
await this.db.delete([this.label, id]);
|
||||
}
|
||||
|
||||
|
@ -91,7 +105,7 @@ class Manager<T> {
|
|||
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<T>, value as T] as const;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue