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",
"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=="],

View File

@ -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"
},

View File

@ -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<string, string>({ 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(/^:(?<term>.+):(?<languageCode>.+):$/);
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);
},
});

View File

@ -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);

View File

@ -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<string, number>();
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"

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 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,
},
],
});
}

View File

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

View File

@ -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<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>();
if (!res.ok || !data) return null;