commit 5a40f83a74d13af6f1a69e1e0f455f17b7ad08c2 Author: JOLIMAITRE Matthieu Date: Mon Apr 29 10:36:03 2024 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a2e95c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/token \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..cbac569 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "deno.enable": true +} diff --git a/data/profile.png b/data/profile.png new file mode 100644 index 0000000..5e271b8 Binary files /dev/null and b/data/profile.png differ diff --git a/data/profile.xcf b/data/profile.xcf new file mode 100644 index 0000000..9d691b9 Binary files /dev/null and b/data/profile.xcf differ diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..dd71398 --- /dev/null +++ b/deno.json @@ -0,0 +1,6 @@ +{ + "fmt": { + "lineWidth": 120, + "useTabs": true + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..e34d1f5 --- /dev/null +++ b/deno.lock @@ -0,0 +1,189 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "npm:discord.js": "npm:discord.js@14.14.1" + }, + "npm": { + "@discordjs/builders@1.7.0": { + "integrity": "sha512-GDtbKMkg433cOZur8Dv6c25EHxduNIBsxeHrsRoIM8+AwmEZ8r0tEpckx/sHwTLwQPOF3e2JWloZh9ofCaMfAw==", + "dependencies": { + "@discordjs/formatters": "@discordjs/formatters@0.3.3", + "@discordjs/util": "@discordjs/util@1.0.2", + "@sapphire/shapeshift": "@sapphire/shapeshift@3.9.7", + "discord-api-types": "discord-api-types@0.37.61", + "fast-deep-equal": "fast-deep-equal@3.1.3", + "ts-mixer": "ts-mixer@6.0.4", + "tslib": "tslib@2.6.2" + } + }, + "@discordjs/collection@1.5.3": { + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "dependencies": {} + }, + "@discordjs/collection@2.0.0": { + "integrity": "sha512-YTWIXLrf5FsrLMycpMM9Q6vnZoR/lN2AWX23/Cuo8uOOtS8eHB2dyQaaGnaF8aZPYnttf2bkLMcXn/j6JUOi3w==", + "dependencies": {} + }, + "@discordjs/formatters@0.3.3": { + "integrity": "sha512-wTcI1Q5cps1eSGhl6+6AzzZkBBlVrBdc9IUhJbijRgVjCNIIIZPgqnUj3ntFODsHrdbGU8BEG9XmDQmgEEYn3w==", + "dependencies": { + "discord-api-types": "discord-api-types@0.37.61" + } + }, + "@discordjs/rest@2.2.0": { + "integrity": "sha512-nXm9wT8oqrYFRMEqTXQx9DUTeEtXUDMmnUKIhZn6O2EeDY9VCdwj23XCPq7fkqMPKdF7ldAfeVKyxxFdbZl59A==", + "dependencies": { + "@discordjs/collection": "@discordjs/collection@2.0.0", + "@discordjs/util": "@discordjs/util@1.0.2", + "@sapphire/async-queue": "@sapphire/async-queue@1.5.2", + "@sapphire/snowflake": "@sapphire/snowflake@3.5.1", + "@vladfrangu/async_event_emitter": "@vladfrangu/async_event_emitter@2.2.4", + "discord-api-types": "discord-api-types@0.37.61", + "magic-bytes.js": "magic-bytes.js@1.10.0", + "tslib": "tslib@2.6.2", + "undici": "undici@5.27.2" + } + }, + "@discordjs/util@1.0.2": { + "integrity": "sha512-IRNbimrmfb75GMNEjyznqM1tkI7HrZOf14njX7tCAAUetyZM1Pr8hX/EK2lxBCOgWDRmigbp24fD1hdMfQK5lw==", + "dependencies": {} + }, + "@discordjs/ws@1.0.2": { + "integrity": "sha512-+XI82Rm2hKnFwAySXEep4A7Kfoowt6weO6381jgW+wVdTpMS/56qCvoXyFRY0slcv7c/U8My2PwIB2/wEaAh7Q==", + "dependencies": { + "@discordjs/collection": "@discordjs/collection@2.0.0", + "@discordjs/rest": "@discordjs/rest@2.2.0", + "@discordjs/util": "@discordjs/util@1.0.2", + "@sapphire/async-queue": "@sapphire/async-queue@1.5.2", + "@types/ws": "@types/ws@8.5.9", + "@vladfrangu/async_event_emitter": "@vladfrangu/async_event_emitter@2.2.4", + "discord-api-types": "discord-api-types@0.37.61", + "tslib": "tslib@2.6.2", + "ws": "ws@8.14.2" + } + }, + "@fastify/busboy@2.1.1": { + "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", + "dependencies": {} + }, + "@sapphire/async-queue@1.5.2": { + "integrity": "sha512-7X7FFAA4DngXUl95+hYbUF19bp1LGiffjJtu7ygrZrbdCSsdDDBaSjB7Akw0ZbOu6k0xpXyljnJ6/RZUvLfRdg==", + "dependencies": {} + }, + "@sapphire/shapeshift@3.9.7": { + "integrity": "sha512-4It2mxPSr4OGn4HSQWGmhFMsNFGfFVhWeRPCRwbH972Ek2pzfGRZtb0pJ4Ze6oIzcyh2jw7nUDa6qGlWofgd9g==", + "dependencies": { + "fast-deep-equal": "fast-deep-equal@3.1.3", + "lodash": "lodash@4.17.21" + } + }, + "@sapphire/snowflake@3.5.1": { + "integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==", + "dependencies": {} + }, + "@types/node@18.16.19": { + "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", + "dependencies": {} + }, + "@types/ws@8.5.9": { + "integrity": "sha512-jbdrY0a8lxfdTp/+r7Z4CkycbOFN8WX+IOchLJr3juT/xzbJ8URyTVSJ/hvNdadTgM1mnedb47n+Y31GsFnQlg==", + "dependencies": { + "@types/node": "@types/node@18.16.19" + } + }, + "@vladfrangu/async_event_emitter@2.2.4": { + "integrity": "sha512-ButUPz9E9cXMLgvAW8aLAKKJJsPu1dY1/l/E8xzLFuysowXygs6GBcyunK9rnGC4zTsnIc2mQo71rGw9U+Ykug==", + "dependencies": {} + }, + "discord-api-types@0.37.61": { + "integrity": "sha512-o/dXNFfhBpYHpQFdT6FWzeO7pKc838QeeZ9d91CfVAtpr5XLK4B/zYxQbYgPdoMiTDvJfzcsLW5naXgmHGDNXw==", + "dependencies": {} + }, + "discord.js@14.14.1": { + "integrity": "sha512-/hUVzkIerxKHyRKopJy5xejp4MYKDPTszAnpYxzVVv4qJYf+Tkt+jnT2N29PIPschicaEEpXwF2ARrTYHYwQ5w==", + "dependencies": { + "@discordjs/builders": "@discordjs/builders@1.7.0", + "@discordjs/collection": "@discordjs/collection@1.5.3", + "@discordjs/formatters": "@discordjs/formatters@0.3.3", + "@discordjs/rest": "@discordjs/rest@2.2.0", + "@discordjs/util": "@discordjs/util@1.0.2", + "@discordjs/ws": "@discordjs/ws@1.0.2", + "@sapphire/snowflake": "@sapphire/snowflake@3.5.1", + "@types/ws": "@types/ws@8.5.9", + "discord-api-types": "discord-api-types@0.37.61", + "fast-deep-equal": "fast-deep-equal@3.1.3", + "lodash.snakecase": "lodash.snakecase@4.1.1", + "tslib": "tslib@2.6.2", + "undici": "undici@5.27.2", + "ws": "ws@8.14.2" + } + }, + "fast-deep-equal@3.1.3": { + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dependencies": {} + }, + "lodash.snakecase@4.1.1": { + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dependencies": {} + }, + "lodash@4.17.21": { + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dependencies": {} + }, + "magic-bytes.js@1.10.0": { + "integrity": "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==", + "dependencies": {} + }, + "ts-mixer@6.0.4": { + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "dependencies": {} + }, + "tslib@2.6.2": { + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dependencies": {} + }, + "undici@5.27.2": { + "integrity": "sha512-iS857PdOEy/y3wlM3yRp+6SNQQ6xU0mmZcwRSriqk+et/cwWAtwmIGf6WkoDN2EK/AMdCO/dfXzIwi+rFMrjjQ==", + "dependencies": { + "@fastify/busboy": "@fastify/busboy@2.1.1" + } + }, + "ws@8.14.2": { + "integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==", + "dependencies": {} + } + } + }, + "redirects": { + "https://deno.land/x/zod/mod.ts": "https://deno.land/x/zod@v3.23.4/mod.ts" + }, + "remote": { + "https://deno.land/std@0.223.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834", + "https://deno.land/std@0.223.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd", + "https://deno.land/std@0.223.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917", + "https://deno.land/std@0.223.0/path/_common/assert_path.ts": "dbdd757a465b690b2cc72fc5fb7698c51507dec6bfafce4ca500c46b76ff7bd8", + "https://deno.land/std@0.223.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", + "https://deno.land/std@0.223.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", + "https://deno.land/std@0.223.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", + "https://deno.land/std@0.223.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", + "https://deno.land/std@0.223.0/path/basename.ts": "7ee495c2d1ee516ffff48fb9a93267ba928b5a3486b550be73071bc14f8cc63e", + "https://deno.land/std@0.223.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", + "https://deno.land/std@0.223.0/path/posix/basename.ts": "d2fa5fbbb1c5a3ab8b9326458a8d4ceac77580961b3739cd5bfd1d3541a3e5f0", + "https://deno.land/std@0.223.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", + "https://deno.land/std@0.223.0/path/windows/basename.ts": "6bbc57bac9df2cec43288c8c5334919418d784243a00bc10de67d392ab36d660", + "https://deno.land/x/zod@v3.23.4/ZodError.ts": "528da200fbe995157b9ae91498b103c4ef482217a5c086249507ac850bd78f52", + "https://deno.land/x/zod@v3.23.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef", + "https://deno.land/x/zod@v3.23.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe", + "https://deno.land/x/zod@v3.23.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c", + "https://deno.land/x/zod@v3.23.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7", + "https://deno.land/x/zod@v3.23.4/helpers/parseUtil.ts": "c14814d167cc286972b6e094df88d7d982572a08424b7cd50f862036b6fcaa77", + "https://deno.land/x/zod@v3.23.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7", + "https://deno.land/x/zod@v3.23.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e", + "https://deno.land/x/zod@v3.23.4/helpers/util.ts": "3301a69867c9e589ac5b3bc4d7a518b5212858cd6a25e8b02d635c9c32ba331c", + "https://deno.land/x/zod@v3.23.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268", + "https://deno.land/x/zod@v3.23.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c", + "https://deno.land/x/zod@v3.23.4/mod.ts": "ec6e2b1255c1a350b80188f97bd0a6bac45801bb46fc48f50b9763aa66046039", + "https://deno.land/x/zod@v3.23.4/types.ts": "7641b9850663f368f568c243eac418fa19834e78b31a866c73772116caa53e7d" + } +} diff --git a/local/.gitignore b/local/.gitignore new file mode 100644 index 0000000..4b052c9 --- /dev/null +++ b/local/.gitignore @@ -0,0 +1 @@ +/db* \ No newline at end of file diff --git a/local/.gitkeep b/local/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/bot.ts b/src/bot.ts new file mode 100755 index 0000000..a617bc4 --- /dev/null +++ b/src/bot.ts @@ -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) { + 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(); diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..a42e976 --- /dev/null +++ b/src/lib/storage.ts @@ -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 { + db; + label; + parser; + + constructor(db: Deno.Kv, label: string, parser: z.ZodType) { + 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()) _; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..fa38a49 --- /dev/null +++ b/src/lib/utils.ts @@ -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(items: Iterable) { + 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(iterator: AsyncIterable) { + const collected = [] as T[]; + for await (const item of iterator) collected.push(item); + return collected; +} + +export type Channel = ReturnType>; +export function channel() { + 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((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)); +}