From 3e2d9634a9d74a75bf964e38cc1bb700257793b1 Mon Sep 17 00:00:00 2001 From: artie Date: Sun, 9 Feb 2025 01:45:48 +0100 Subject: [PATCH] wiktionary + error handling improvements --- bun.lock | 6 ++ package.json | 2 + src/commands/language/wiktionary.ts | 141 +++++++++++----------------- src/env.ts | 1 + src/events/interactionCreate.ts | 15 ++- src/utils/error.ts | 17 +++- src/utils/functions.ts | 3 + src/utils/wiktionary.ts | 34 ++++++- 8 files changed, 124 insertions(+), 95 deletions(-) diff --git a/bun.lock b/bun.lock index 9330814..207dada 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,8 @@ "cheerio": "^1.0.0", "discord.js": "^14.17.3", "ky": "^1.7.4", + "lru-cache": "^11.0.2", + "nanoid": "^5.0.9", "winston": "^3.17.0", "zod": "^3.24.1", }, @@ -286,6 +288,8 @@ "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + "lru-cache": ["lru-cache@11.0.2", "", {}, "sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA=="], + "magic-bytes.js": ["magic-bytes.js@1.10.0", "", {}, "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -296,6 +300,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nanoid": ["nanoid@5.0.9", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-Aooyr6MXU6HpvvWXKoVoXwKMs/KyVakWwg7xQfv5/S/RIgJMy0Ifa45H9qqYy7pTCszrHzP21Uk4PZq2HpEM8Q=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], diff --git a/package.json b/package.json index c5dd4ed..add366a 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "cheerio": "^1.0.0", "discord.js": "^14.17.3", "ky": "^1.7.4", + "lru-cache": "^11.0.2", + "nanoid": "^5.0.9", "winston": "^3.17.0", "zod": "^3.24.1" }, diff --git a/src/commands/language/wiktionary.ts b/src/commands/language/wiktionary.ts index ac6b057..9d6791b 100644 --- a/src/commands/language/wiktionary.ts +++ b/src/commands/language/wiktionary.ts @@ -1,12 +1,11 @@ -import { - bold, - EmbedBuilder, - inlineCode, - SlashCommandBuilder, -} from "discord.js"; +import { bold, inlineCode, SlashCommandBuilder } from "discord.js"; import { defineCommand } from ".."; -import { getDefinitions } from "../../utils/wiktionary"; +import { getDefinitions, getSuggestions } from "../../utils/wiktionary"; import { stripHtml } from "../../utils/functions"; +import { PaginatedMessage } from "@sapphire/discord.js-utilities"; +import { LRUCache } from "lru-cache"; + +const titleCache = new LRUCache({ max: 100 }); export default defineCommand({ data: new SlashCommandBuilder() @@ -21,48 +20,26 @@ export default defineCommand({ ), async autocomplete(interaction) { - let language: string | undefined; - let term = interaction.options.getFocused().trim(); + const term = interaction.options.getFocused().trim(); if (term.length < 3) { await interaction.respond([]); return; } - const parsed = term.split(":"); - if (parsed.length === 2) { - term = parsed[0].trim(); - language = parsed[1].trim(); - if (!language) { - await interaction.respond([]); - return; - } - } - - const definitions = await getDefinitions(term); - if (!definitions) { + const suggestions = await getSuggestions(term); + if (!suggestions) { await interaction.respond([]); return; } - if (language) { - const choices = definitions - .filter((definition) => - definition.language.toLowerCase().startsWith(language.toLowerCase()) - ) - .map((definition) => ({ - name: `${term} (${definition.language})`, - value: `:${term}:${definition.languageCode}:`, - })) - .slice(0, 25); + suggestions.forEach((suggestion) => { + titleCache.set(suggestion.key, suggestion.title); + }); - await interaction.respond(choices); - return; - } - - const choices = definitions - .map((definition) => ({ - name: `${term} (${definition.language})`, - value: `:${term}:${definition.languageCode}:`, + const choices = suggestions + .map((suggestion) => ({ + name: suggestion.title, + value: suggestion.key, })) .slice(0, 25); @@ -70,62 +47,56 @@ export default defineCommand({ }, async execute(interaction) { - let term = interaction.options.getString("term", true); - let languageCode: string | undefined; - - const parsed = term.match(/^:(?.+):(?.+):$/); - if (parsed?.groups) { - term = parsed.groups.term; - languageCode = parsed.groups.languageCode; - } + const term = interaction.options.getString("term", true); const definitions = await getDefinitions(term); - if (!definitions) { + if (!definitions?.length) { await interaction.reply({ content: "No definitions found", }); return; } - const definition = languageCode - ? definitions.find((def) => def.languageCode === languageCode) - : definitions[0]; - if (!definition) { - await interaction.reply({ - content: "No definitions found", - }); - return; - } + const title = titleCache.get(term) ?? term; + const msg = new PaginatedMessage(); + msg.setSelectMenuOptions((i) => ({ + label: definitions[i - 1].language, + description: `Page ${i}`, + })); - const description = definition.entries - .map((entry) => { - const name = entry.partOfSpeech; - const definitions = entry.definitions - .filter((def) => def.definition) - .map((def, i) => { - const prefix = inlineCode(`${i + 1}.`); - const definition = stripHtml(def.definition); - return `${prefix} ${definition.trim()}`; + definitions.forEach((definition) => { + const description = definition.entries + .map((entry) => { + const name = entry.partOfSpeech; + const definitions = entry.definitions + .filter((def) => def.definition) + .map((def, i) => { + const prefix = inlineCode(`${i + 1}.`); + const definition = stripHtml(def.definition); + return `${prefix} ${definition.trim()}`; + }) + .join("\n"); + + return `${bold(name)}\n${definitions}`; + }) + .join("\n\n"); + + msg.addPageEmbed((embed) => + embed + .setAuthor({ + name: `Wiktionary - ${definition.language}`, + iconURL: + "https://en.wiktionary.org/static/apple-touch/wiktionary/en.png", + url: `https://${ + definition.languageCode + }.wiktionary.org/wiki/${encodeURIComponent(term)}`, }) - .join("\n"); + .setTitle(title) + .setColor(0xfefefe) + .setDescription(description) + ); + }); - return `${bold(name)}\n${definitions}`; - }) - .join("\n\n"); - - const embed = new EmbedBuilder() - .setAuthor({ - name: `Wiktionary - ${definition.language}`, - iconURL: - "https://en.wiktionary.org/static/apple-touch/wiktionary/en.png", - url: `https://${ - definition.languageCode - }.wiktionary.org/wiki/${encodeURIComponent(term)}`, - }) - .setTitle(term) - .setColor(0xfefefe) - .setDescription(description); - - await interaction.reply({ embeds: [embed] }); + msg.run(interaction); }, }); diff --git a/src/env.ts b/src/env.ts index 4a939de..3867ff5 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,7 @@ const envSchema = z.object({ .optional() .default("development"), DEV_GUILD_ID: z.string(), + DEV_CHANNEL_ID: z.string(), }); export const env = envSchema.parse(process.env); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 83dd20a..840e0ec 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -2,12 +2,14 @@ import { AutocompleteInteraction, ChatInputCommandInteraction, Events, + inlineCode, MessageFlags, } from "discord.js"; import { client } from "../client"; import { log } from "../utils/logger"; import { defineEvent } from "."; -import { isCommandError, isError } from "../utils/error"; +import { isCommandError, notifyError } from "../utils/error"; +import { nanoid } from "../utils/functions"; const running = new Map(); const getRunning = (command: string) => running.get(command) ?? 0; @@ -52,13 +54,16 @@ async function handleChatInputCommand( try { await command.execute(interaction); } catch (err) { - const content = isCommandError(err) - ? err.message - : isError(err) + let content = isCommandError(err) ? err.message : "An unknown error occurred!"; - if (!isCommandError(err)) log.error("Unhandled Command Error", err); + if (!isCommandError(err)) { + const trace = nanoid(); + content += `\ntrace: ${inlineCode(trace)}`; + log.error("Unhandled Command Error", { trace, err }); + notifyError(trace, err); + } await interaction[ interaction.replied || interaction.deferred ? "followUp" : "reply" diff --git a/src/utils/error.ts b/src/utils/error.ts index c24cb82..681e45f 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -1,9 +1,22 @@ +import { codeBlock, type TextChannel } from "discord.js"; +import { client } from "../client"; +import { env } from "../env"; + export class CommandError extends Error {} export function isCommandError(error: any): error is CommandError { return error instanceof CommandError; } -export function isError(error: any): error is Error { - return error instanceof Error; +export async function notifyError(trace: string, error: any) { + return (client.channels.cache.get(env.DEV_CHANNEL_ID) as TextChannel).send({ + content: trace, + embeds: [ + { + title: "Unhandled Error", + description: codeBlock("js", error.stack ?? error.message), + color: 0xff0000, + }, + ], + }); } diff --git a/src/utils/functions.ts b/src/utils/functions.ts index ec70628..e818180 100644 --- a/src/utils/functions.ts +++ b/src/utils/functions.ts @@ -1,4 +1,7 @@ import * as cheerio from "cheerio"; +import { customAlphabet } from "nanoid"; + +export const nanoid = customAlphabet("1234567890abcdef"); export function noop() {} diff --git a/src/utils/wiktionary.ts b/src/utils/wiktionary.ts index 5920469..6fb58f9 100644 --- a/src/utils/wiktionary.ts +++ b/src/utils/wiktionary.ts @@ -1,5 +1,14 @@ import ky from "ky"; +type Suggestion = { + key: string; + title: string; +}; + +type Suggestions = { + pages: Suggestion[]; +}; + type ParsedExample = { example: string; }; @@ -19,13 +28,32 @@ type DefinitionsResponse = { [key: string]: Entry[]; }; -const client = ky.create({ +const restClient = ky.create({ prefixUrl: "https://en.wiktionary.org/api/rest_v1", throwHttpErrors: false, }); -export async function getDefinitions(word: string) { - const res = await client.get("page/definition/" + encodeURIComponent(word)); +const phpClient = ky.create({ + prefixUrl: "https://en.wiktionary.org/w/rest.php/v1", + throwHttpErrors: false, +}); + +export async function getSuggestions(term: string) { + const res = await phpClient.get("search/title", { + searchParams: { + q: term, + limit: 25, + }, + }); + const data = await res.json(); + if (!res.ok || !data.pages.length) return null; + return data.pages; +} + +export async function getDefinitions(term: string) { + const res = await restClient.get( + "page/definition/" + encodeURIComponent(term) + ); const data = await res.json(); if (!res.ok || !data) return null;