aboutsummaryrefslogtreecommitdiff
path: root/174bg/discord-bot
diff options
context:
space:
mode:
Diffstat (limited to '174bg/discord-bot')
-rw-r--r--174bg/discord-bot/source/commands/_index.js5
-rw-r--r--174bg/discord-bot/source/commands/clear-cache.js27
-rw-r--r--174bg/discord-bot/source/controllers/uex.js20
-rw-r--r--174bg/discord-bot/source/utilities/cache.js66
4 files changed, 111 insertions, 7 deletions
diff --git a/174bg/discord-bot/source/commands/_index.js b/174bg/discord-bot/source/commands/_index.js
index 5fea200..25154e4 100644
--- a/174bg/discord-bot/source/commands/_index.js
+++ b/174bg/discord-bot/source/commands/_index.js
@@ -2,7 +2,8 @@ import requisition from "./requisition.js";
import closeTicket from "./close-ticket.js";
import oncallCheck from "./oncall-check.js";
import uex from "./uex.js";
+import clearCache from "./clear-cache.js";
-export { requisition, closeTicket, oncallCheck, uex };
+export { requisition, closeTicket, oncallCheck, uex, clearCache };
-export default [requisition, closeTicket, oncallCheck, uex];
+export default [requisition, closeTicket, oncallCheck, uex, clearCache];
diff --git a/174bg/discord-bot/source/commands/clear-cache.js b/174bg/discord-bot/source/commands/clear-cache.js
new file mode 100644
index 0000000..ad99723
--- /dev/null
+++ b/174bg/discord-bot/source/commands/clear-cache.js
@@ -0,0 +1,27 @@
+import { SlashCommandBuilder, PermissionFlagsBits, MessageFlags } from "discord.js";
+import fs from "fs";
+
+export default {
+ data: new SlashCommandBuilder()
+ .setName("clear-cache")
+ .setDescription(
+ "Clears the bot's cache.",
+ )
+ .setDefaultMemberPermissions(PermissionFlagsBits.Administrator),
+ execute: async (interaction) => {
+ const cacheDir = ".cache";
+
+ if (fs.existsSync(cacheDir)) {
+ fs.rmSync(cacheDir, { recursive: true, force: true });
+ await interaction.reply({
+ content: "Cache cleared successfully.",
+ flags: MessageFlags.Ephemeral,
+ });
+ } else {
+ await interaction.reply({
+ content: "Cache directory does not exist.",
+ flags: MessageFlags.Ephemeral,
+ });
+ }
+ },
+};
diff --git a/174bg/discord-bot/source/controllers/uex.js b/174bg/discord-bot/source/controllers/uex.js
index 615b517..827013a 100644
--- a/174bg/discord-bot/source/controllers/uex.js
+++ b/174bg/discord-bot/source/controllers/uex.js
@@ -1,24 +1,34 @@
import { get } from "./http.js";
+import { cached } from "../utilities/cache.js";
export async function categories() {
- return await get("https://api.uexcorp.uk/2.0/categories");
+ return await cached("uex", "categories", 60 * 60 * 1000 * 24, () =>
+ get("https://api.uexcorp.uk/2.0/categories"),
+ );
}
export async function items(id_category) {
let items = [];
if (!id_category) {
- const { data: categories } = await get("https://api.uexcorp.uk/2.0/categories");
+ const { data: allCategories } = await categories();
- for (const category of categories) {
- const { data: categoryItems } = await get(`https://api.uexcorp.uk/2.0/items?id_category=${category.id}`);
+ for (const category of allCategories) {
+ const { data: categoryItems } = await cached(
+ "uex",
+ `items:${category.id}`,
+ 60 * 60 * 1000 * 24,
+ () => get(`https://api.uexcorp.uk/2.0/items?id_category=${category.id}`),
+ );
if (!categoryItems) continue;
items = items.concat(categoryItems);
}
} else {
- const { data } = await get(`https://api.uexcorp.uk/2.0/items?category=${id_category}`);
+ const { data } = await cached("uex", `items:${id_category}`, 60 * 60 * 1000 * 24, () =>
+ get(`https://api.uexcorp.uk/2.0/items?category=${id_category}`),
+ );
items = data;
}
diff --git a/174bg/discord-bot/source/utilities/cache.js b/174bg/discord-bot/source/utilities/cache.js
new file mode 100644
index 0000000..229a3d0
--- /dev/null
+++ b/174bg/discord-bot/source/utilities/cache.js
@@ -0,0 +1,66 @@
+/**
+ * Utility module for caching arbitrary JSON-serializable values to disk with an expiry,
+ * to avoid re-fetching data (e.g. from external APIs) more often than necessary.
+ *
+ * Cache entries are stored under `.cache/<namespace>/<hashed-key>.json` at the project root.
+ */
+
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { createHash } from "node:crypto";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const CACHE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".cache");
+
+function keyToFileName(key) {
+ return `${createHash("sha256").update(key).digest("hex")}.json`;
+}
+
+async function readEntry(namespace, key) {
+ const filePath = path.join(CACHE_ROOT, namespace, keyToFileName(key));
+
+ try {
+ const { expiresAt, data } = JSON.parse(await readFile(filePath, "utf-8"));
+
+ if (Date.now() >= expiresAt) return undefined;
+
+ return data;
+ } catch {
+ // missing, unreadable, or corrupt cache entry - treat as a cache miss
+ return undefined;
+ }
+}
+
+async function writeEntry(namespace, key, data, ttlMs) {
+ const dir = path.join(CACHE_ROOT, namespace);
+
+ await mkdir(dir, { recursive: true });
+ await writeFile(
+ path.join(dir, keyToFileName(key)),
+ JSON.stringify({ expiresAt: Date.now() + ttlMs, data }),
+ );
+}
+
+/**
+ * Returns the cached value for `key` within `namespace` if it exists and hasn't expired.
+ * Otherwise, calls `fetcher`, caches its result for `ttlMs` milliseconds, and returns it.
+ * @param {string} namespace - Subdirectory under `.cache` to group related entries (e.g. "uex").
+ * @param {string} key - Unique identifier for this cached value within the namespace.
+ * @param {number} ttlMs - How long the cached value should remain valid, in milliseconds.
+ * @param {() => Promise<any>} fetcher - Called to produce a fresh value on a cache miss.
+ */
+export async function cached(namespace, key, ttlMs, fetcher) {
+ const existing = await readEntry(namespace, key);
+ if (existing !== undefined) return existing;
+
+ const data = await fetcher();
+
+ // don't cache failed/empty lookups so they get retried on the next call
+ if (data !== null && data !== undefined) {
+ await writeEntry(namespace, key, data, ttlMs);
+ }
+
+ return data;
+}
+
+export default { cached };