wiktionary + error handling improvements

This commit is contained in:
artie 2025-02-09 01:45:48 +01:00
parent 5fa93ca95e
commit 3e2d9634a9
8 changed files with 124 additions and 95 deletions

View File

@ -9,6 +9,8 @@
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"discord.js": "^14.17.3", "discord.js": "^14.17.3",
"ky": "^1.7.4", "ky": "^1.7.4",
"lru-cache": "^11.0.2",
"nanoid": "^5.0.9",
"winston": "^3.17.0", "winston": "^3.17.0",
"zod": "^3.24.1", "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=="], "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=="], "magic-bytes.js": ["magic-bytes.js@1.10.0", "", {}, "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "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=="], "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=="], "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=="], "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],

View File

@ -15,6 +15,8 @@
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"discord.js": "^14.17.3", "discord.js": "^14.17.3",
"ky": "^1.7.4", "ky": "^1.7.4",
"lru-cache": "^11.0.2",
"nanoid": "^5.0.9",
"winston": "^3.17.0", "winston": "^3.17.0",
"zod": "^3.24.1" "zod": "^3.24.1"
}, },

View File

@ -1,12 +1,11 @@
import { import { bold, inlineCode, SlashCommandBuilder } from "discord.js";
bold,
EmbedBuilder,
inlineCode,
SlashCommandBuilder,
} from "discord.js";
import { defineCommand } from ".."; import { defineCommand } from "..";
import { getDefinitions } from "../../utils/wiktionary"; import { getDefinitions, getSuggestions } from "../../utils/wiktionary";
import { stripHtml } from "../../utils/functions"; import { stripHtml } from "../../utils/functions";
import { PaginatedMessage } from "@sapphire/discord.js-utilities";
import { LRUCache } from "lru-cache";
const titleCache = new LRUCache<string, string>({ max: 100 });
export default defineCommand({ export default defineCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@ -21,48 +20,26 @@ export default defineCommand({
), ),
async autocomplete(interaction) { async autocomplete(interaction) {
let language: string | undefined; const term = interaction.options.getFocused().trim();
let term = interaction.options.getFocused().trim();
if (term.length < 3) { if (term.length < 3) {
await interaction.respond([]); await interaction.respond([]);
return; return;
} }
const parsed = term.split(":"); const suggestions = await getSuggestions(term);
if (parsed.length === 2) { if (!suggestions) {
term = parsed[0].trim();
language = parsed[1].trim();
if (!language) {
await interaction.respond([]);
return;
}
}
const definitions = await getDefinitions(term);
if (!definitions) {
await interaction.respond([]); await interaction.respond([]);
return; return;
} }
if (language) { suggestions.forEach((suggestion) => {
const choices = definitions titleCache.set(suggestion.key, suggestion.title);
.filter((definition) => });
definition.language.toLowerCase().startsWith(language.toLowerCase())
)
.map((definition) => ({
name: `${term} (${definition.language})`,
value: `:${term}:${definition.languageCode}:`,
}))
.slice(0, 25);
await interaction.respond(choices); const choices = suggestions
return; .map((suggestion) => ({
} name: suggestion.title,
value: suggestion.key,
const choices = definitions
.map((definition) => ({
name: `${term} (${definition.language})`,
value: `:${term}:${definition.languageCode}:`,
})) }))
.slice(0, 25); .slice(0, 25);
@ -70,62 +47,56 @@ export default defineCommand({
}, },
async execute(interaction) { async execute(interaction) {
let term = interaction.options.getString("term", true); const term = interaction.options.getString("term", true);
let languageCode: string | undefined;
const parsed = term.match(/^:(?<term>.+):(?<languageCode>.+):$/);
if (parsed?.groups) {
term = parsed.groups.term;
languageCode = parsed.groups.languageCode;
}
const definitions = await getDefinitions(term); const definitions = await getDefinitions(term);
if (!definitions) { if (!definitions?.length) {
await interaction.reply({ await interaction.reply({
content: "No definitions found", content: "No definitions found",
}); });
return; return;
} }
const definition = languageCode const title = titleCache.get(term) ?? term;
? definitions.find((def) => def.languageCode === languageCode) const msg = new PaginatedMessage();
: definitions[0]; msg.setSelectMenuOptions((i) => ({
if (!definition) { label: definitions[i - 1].language,
await interaction.reply({ description: `Page ${i}`,
content: "No definitions found", }));
});
return;
}
const description = definition.entries definitions.forEach((definition) => {
.map((entry) => { const description = definition.entries
const name = entry.partOfSpeech; .map((entry) => {
const definitions = entry.definitions const name = entry.partOfSpeech;
.filter((def) => def.definition) const definitions = entry.definitions
.map((def, i) => { .filter((def) => def.definition)
const prefix = inlineCode(`${i + 1}.`); .map((def, i) => {
const definition = stripHtml(def.definition); const prefix = inlineCode(`${i + 1}.`);
return `${prefix} ${definition.trim()}`; 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}`; msg.run(interaction);
})
.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] });
}, },
}); });

View File

@ -9,6 +9,7 @@ const envSchema = z.object({
.optional() .optional()
.default("development"), .default("development"),
DEV_GUILD_ID: z.string(), DEV_GUILD_ID: z.string(),
DEV_CHANNEL_ID: z.string(),
}); });
export const env = envSchema.parse(process.env); export const env = envSchema.parse(process.env);

View File

@ -2,12 +2,14 @@ import {
AutocompleteInteraction, AutocompleteInteraction,
ChatInputCommandInteraction, ChatInputCommandInteraction,
Events, Events,
inlineCode,
MessageFlags, MessageFlags,
} from "discord.js"; } from "discord.js";
import { client } from "../client"; import { client } from "../client";
import { log } from "../utils/logger"; import { log } from "../utils/logger";
import { defineEvent } from "."; import { defineEvent } from ".";
import { isCommandError, isError } from "../utils/error"; import { isCommandError, notifyError } from "../utils/error";
import { nanoid } from "../utils/functions";
const running = new Map<string, number>(); const running = new Map<string, number>();
const getRunning = (command: string) => running.get(command) ?? 0; const getRunning = (command: string) => running.get(command) ?? 0;
@ -52,13 +54,16 @@ async function handleChatInputCommand(
try { try {
await command.execute(interaction); await command.execute(interaction);
} catch (err) { } catch (err) {
const content = isCommandError(err) let content = isCommandError(err)
? err.message
: isError(err)
? err.message ? err.message
: "An unknown error occurred!"; : "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[ await interaction[
interaction.replied || interaction.deferred ? "followUp" : "reply" interaction.replied || interaction.deferred ? "followUp" : "reply"

View File

@ -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 class CommandError extends Error {}
export function isCommandError(error: any): error is CommandError { export function isCommandError(error: any): error is CommandError {
return error instanceof CommandError; return error instanceof CommandError;
} }
export function isError(error: any): error is Error { export async function notifyError(trace: string, error: any) {
return error instanceof Error; 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,
},
],
});
} }

View File

@ -1,4 +1,7 @@
import * as cheerio from "cheerio"; import * as cheerio from "cheerio";
import { customAlphabet } from "nanoid";
export const nanoid = customAlphabet("1234567890abcdef");
export function noop() {} export function noop() {}

View File

@ -1,5 +1,14 @@
import ky from "ky"; import ky from "ky";
type Suggestion = {
key: string;
title: string;
};
type Suggestions = {
pages: Suggestion[];
};
type ParsedExample = { type ParsedExample = {
example: string; example: string;
}; };
@ -19,13 +28,32 @@ type DefinitionsResponse = {
[key: string]: Entry[]; [key: string]: Entry[];
}; };
const client = ky.create({ const restClient = ky.create({
prefixUrl: "https://en.wiktionary.org/api/rest_v1", prefixUrl: "https://en.wiktionary.org/api/rest_v1",
throwHttpErrors: false, throwHttpErrors: false,
}); });
export async function getDefinitions(word: string) { const phpClient = ky.create({
const res = await client.get("page/definition/" + encodeURIComponent(word)); 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<Suggestions>();
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<DefinitionsResponse>(); const data = await res.json<DefinitionsResponse>();
if (!res.ok || !data) return null; if (!res.ok || !data) return null;