From 7cb09bc93ac33d827edf71df9694fb3947a319d9 Mon Sep 17 00:00:00 2001 From: artie Date: Sat, 15 Feb 2025 15:54:35 +0100 Subject: [PATCH] add translate modals and fuzzy search --- bun.lock | 15 ++-- package.json | 1 + src/client.ts | 11 ++- src/commands/language/translate.ts | 12 +-- src/commands/language/translateEnglishMenu.ts | 22 +++++ src/commands/language/translateMenu.ts | 89 +++++++++++++++++-- src/commands/ocr/ocr.ts | 8 +- src/commands/ocr/ocrTranslate.ts | 4 +- src/commands/ocr/ocrTranslateEnglishMenu.ts | 27 ++++++ src/commands/ocr/ocrTranslateMenu.ts | 48 ++++++++-- src/commands/utility/httpcat.ts | 39 ++++++-- src/utils/deepl.ts | 27 ++++++ 12 files changed, 266 insertions(+), 37 deletions(-) create mode 100644 src/commands/language/translateEnglishMenu.ts create mode 100644 src/commands/ocr/ocrTranslateEnglishMenu.ts diff --git a/bun.lock b/bun.lock index 9d955e8..d0bed6c 100644 --- a/bun.lock +++ b/bun.lock @@ -9,26 +9,27 @@ "cheerio": "^1.0.0", "chrome-lens-ocr": "^4.0.4", "deepl-node": "^1.16.0", - "discord.js": "^14.17.3", + "discord.js": "^14.18.0", "execa": "^9.5.2", "file-type": "^20.1.0", - "ky": "^1.7.4", + "fuse.js": "^7.1.0", + "ky": "^1.7.5", "lru-cache": "^11.0.2", "nanoid": "^5.0.9", "pino": "^9.6.0", "pino-pretty": "^13.0.0", "sharp": "^0.33.5", "ya-ocr": "^1.1.0", - "zod": "^3.24.1", + "zod": "^3.24.2", }, "devDependencies": { "@eslint/js": "^9.20.0", "@types/bun": "latest", - "eslint": "^9.20.0", - "typescript-eslint": "^8.23.0", + "eslint": "^9.20.1", + "typescript-eslint": "^8.24.0", }, "peerDependencies": { - "typescript": "^5.0.0", + "typescript": "^5.7.3", }, }, }, @@ -315,6 +316,8 @@ "form-data": ["form-data@3.0.2", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, "sha512-sJe+TQb2vIaIyO783qN6BlMYWMw3WBOHA1Ay2qxsnjuafEOQFJ2JakedOQirT6D5XPRxDvS7AHYyem9fTpb4LQ=="], + "fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="], + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], diff --git a/package.json b/package.json index 18f845c..b3a429c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "discord.js": "^14.18.0", "execa": "^9.5.2", "file-type": "^20.1.0", + "fuse.js": "^7.1.0", "ky": "^1.7.5", "lru-cache": "^11.0.2", "nanoid": "^5.0.9", diff --git a/src/client.ts b/src/client.ts index d92f9ad..fcd7e0f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,10 @@ -import { Client, Collection, GatewayIntentBits, Partials } from "discord.js"; +import { + ActivityType, + Client, + Collection, + GatewayIntentBits, + Partials, +} from "discord.js"; import { REST } from "@discordjs/rest"; import { env } from "./env"; import { API } from "@discordjs/core"; @@ -27,6 +33,9 @@ export class ArtemisClient extends Client { parse: [], }, partials: [Partials.Channel], + presence: { + activities: [{ name: "hecho con ❤️", type: ActivityType.Custom }], + }, }); const rest = new REST().setToken(env.DISCORD_TOKEN); diff --git a/src/commands/language/translate.ts b/src/commands/language/translate.ts index e34463c..0816f16 100644 --- a/src/commands/language/translate.ts +++ b/src/commands/language/translate.ts @@ -3,7 +3,7 @@ import { hyperlink, inlineCode, SlashCommandBuilder, - type InteractionEditReplyOptions, + type InteractionReplyOptions, } from "discord.js"; import { defineCommand } from ".."; import { @@ -44,7 +44,7 @@ export async function translateImpl( target: string, ocrModel?: OCRResult["model"], imageUrl?: string -): Promise { +) { let { translatedText, detectedSourceLang, model } = await translateDeepl( text, source, @@ -74,7 +74,7 @@ export async function translateImpl( ), }, ], - }; + } satisfies InteractionReplyOptions; } return { @@ -98,7 +98,7 @@ export async function translateImpl( : {}), }, ], - }; + } satisfies InteractionReplyOptions; } export default defineCommand({ @@ -136,10 +136,10 @@ export default defineCommand({ await interaction.deferReply(); if (source && !(await isSourceLanguage(source))) { - abort("Source language not supported"); + abort("Source language not found"); } if (target && !(await isTargetLanguage(target))) { - abort("Target language not supported"); + abort("Target language not found"); } const payload = await translateImpl(text, source, target); diff --git a/src/commands/language/translateEnglishMenu.ts b/src/commands/language/translateEnglishMenu.ts new file mode 100644 index 0000000..bf6a263 --- /dev/null +++ b/src/commands/language/translateEnglishMenu.ts @@ -0,0 +1,22 @@ +import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js"; +import { defineCommand } from ".."; +import { translateImpl } from "./translate"; +import { abort } from "../../utils/error"; + +export default defineCommand({ + data: new ContextMenuCommandBuilder() + .setName("Translate to English") + .setType(ApplicationCommandType.Message), + + async execute(interaction) { + if (!interaction.isMessageContextMenuCommand()) return; + + const text = interaction.targetMessage.content; + if (!text) abort("No text to translate"); + + await interaction.deferReply(); + + const payload = await translateImpl(text, null, "en-US"); + await interaction.editReply(payload); + }, +}); diff --git a/src/commands/language/translateMenu.ts b/src/commands/language/translateMenu.ts index bf6a263..e8effaf 100644 --- a/src/commands/language/translateMenu.ts +++ b/src/commands/language/translateMenu.ts @@ -1,11 +1,53 @@ -import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js"; +import { + ActionRowBuilder, + ApplicationCommandType, + ContextMenuCommandBuilder, + type ModalActionRowComponentBuilder, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; import { defineCommand } from ".."; -import { translateImpl } from "./translate"; import { abort } from "../../utils/error"; +import { translateImpl } from "./translate"; +import { findFuzzyLanguage } from "../../utils/deepl"; + +export function buildTranslateModal() { + const modal = new ModalBuilder() + .setTitle("Translate") + .setCustomId("translate-modal"); + + const sourceInput = new TextInputBuilder() + .setLabel("Source language") + .setCustomId("source") + .setStyle(TextInputStyle.Short) + .setMaxLength(20) + .setPlaceholder("en, pl, hungarian, japanese...") + .setRequired(false); + + const targetInput = new TextInputBuilder() + .setLabel("Target language") + .setCustomId("target") + .setStyle(TextInputStyle.Short) + .setMaxLength(20) + .setPlaceholder("en, pl, hungarian, japanese...") + .setRequired(false); + + const sourceRow = + new ActionRowBuilder().addComponents( + sourceInput + ); + const targetRow = + new ActionRowBuilder().addComponents( + targetInput + ); + + return modal.addComponents(sourceRow, targetRow); +} export default defineCommand({ data: new ContextMenuCommandBuilder() - .setName("Translate to English") + .setName("Translate...") .setType(ApplicationCommandType.Message), async execute(interaction) { @@ -14,9 +56,44 @@ export default defineCommand({ const text = interaction.targetMessage.content; if (!text) abort("No text to translate"); - await interaction.deferReply(); + const modal = buildTranslateModal(); + await interaction.showModal(modal); - const payload = await translateImpl(text, null, "en-US"); - await interaction.editReply(payload); + await interaction + .awaitModalSubmit({ + filter: (i) => i.customId === "translate-modal", + time: 60000 * 5, + }) + .then(async (interaction) => { + await interaction.deferReply(); + + const sourceField = + interaction.fields.getTextInputValue("source") || "auto"; + const targetField = + interaction.fields.getTextInputValue("target") || "en-US"; + + const source = + sourceField === "auto" + ? null + : await findFuzzyLanguage(sourceField, "source").then( + (l) => l?.code + ); + const target = + targetField === "en-US" + ? targetField + : await findFuzzyLanguage(targetField, "target").then( + (l) => l?.code + ); + + if (source === undefined) { + abort("Source language not found"); + } + if (!target) { + abort("Target language not found"); + } + + const payload = await translateImpl(text, source, target); + await interaction.editReply(payload); + }); }, }); diff --git a/src/commands/ocr/ocr.ts b/src/commands/ocr/ocr.ts index 2108790..18a18b4 100644 --- a/src/commands/ocr/ocr.ts +++ b/src/commands/ocr/ocr.ts @@ -2,7 +2,7 @@ import { hyperlink, inlineCode, SlashCommandBuilder, - type InteractionEditReplyOptions, + type InteractionReplyOptions, } from "discord.js"; import { defineCommand } from ".."; import { downloadFile } from "../../utils/http"; @@ -23,7 +23,7 @@ export function buildOcrPayload( language: string, model: OCRResult["model"], imageUrl?: string -): InteractionEditReplyOptions { +) { const languageName = languageCodeToName(language) ?? "Unknown"; if (text.length > 4096) { @@ -38,7 +38,7 @@ export function buildOcrPayload( attachment: Buffer.from(text), }, ], - }; + } satisfies InteractionReplyOptions; } return { @@ -62,7 +62,7 @@ export function buildOcrPayload( }, }, ], - }; + } satisfies InteractionReplyOptions; } export async function ocrImpl(url: string) { diff --git a/src/commands/ocr/ocrTranslate.ts b/src/commands/ocr/ocrTranslate.ts index de2815d..c8ba1fd 100644 --- a/src/commands/ocr/ocrTranslate.ts +++ b/src/commands/ocr/ocrTranslate.ts @@ -48,10 +48,10 @@ export default defineCommand({ await interaction.deferReply(); if (source && !(await isSourceLanguage(source))) { - abort("Source language not supported"); + abort("Source language not found"); } if (target && !(await isTargetLanguage(target))) { - abort("Target language not supported"); + abort("Target language not found"); } const { text, model } = await ocrImpl(imageUrl); diff --git a/src/commands/ocr/ocrTranslateEnglishMenu.ts b/src/commands/ocr/ocrTranslateEnglishMenu.ts new file mode 100644 index 0000000..0ed48b5 --- /dev/null +++ b/src/commands/ocr/ocrTranslateEnglishMenu.ts @@ -0,0 +1,27 @@ +import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js"; +import { defineCommand } from ".."; +import { translateImpl } from "../language/translate"; +import { ocrImpl } from "./ocr"; +import { getImageFromAttachmentOrString } from "../../utils/functions"; + +export default defineCommand({ + data: new ContextMenuCommandBuilder() + .setName("OCR and translate to English") + .setType(ApplicationCommandType.Message), + + async execute(interaction) { + if (!interaction.isMessageContextMenuCommand()) return; + + const attachment = interaction.targetMessage.attachments.first(); + const imageUrl = getImageFromAttachmentOrString( + attachment, + interaction.targetMessage.content + ); + + await interaction.deferReply(); + + const { text, model } = await ocrImpl(imageUrl); + const payload = await translateImpl(text, null, "en-US", model); + await interaction.editReply(payload); + }, +}); diff --git a/src/commands/ocr/ocrTranslateMenu.ts b/src/commands/ocr/ocrTranslateMenu.ts index 0ed48b5..9671842 100644 --- a/src/commands/ocr/ocrTranslateMenu.ts +++ b/src/commands/ocr/ocrTranslateMenu.ts @@ -3,10 +3,13 @@ import { defineCommand } from ".."; import { translateImpl } from "../language/translate"; import { ocrImpl } from "./ocr"; import { getImageFromAttachmentOrString } from "../../utils/functions"; +import { buildTranslateModal } from "../language/translateMenu"; +import { abort } from "../../utils/error"; +import { findFuzzyLanguage } from "../../utils/deepl"; export default defineCommand({ data: new ContextMenuCommandBuilder() - .setName("OCR and translate to English") + .setName("OCR and translate...") .setType(ApplicationCommandType.Message), async execute(interaction) { @@ -18,10 +21,45 @@ export default defineCommand({ interaction.targetMessage.content ); - await interaction.deferReply(); + const modal = buildTranslateModal(); + await interaction.showModal(modal); - const { text, model } = await ocrImpl(imageUrl); - const payload = await translateImpl(text, null, "en-US", model); - await interaction.editReply(payload); + await interaction + .awaitModalSubmit({ + filter: (i) => i.customId === "translate-modal", + time: 60000 * 5, + }) + .then(async (interaction) => { + await interaction.deferReply(); + + const sourceField = + interaction.fields.getTextInputValue("source") || null; + const targetField = + interaction.fields.getTextInputValue("target") || "en-US"; + + const source = + sourceField === null + ? sourceField + : await findFuzzyLanguage(sourceField, "source").then( + (l) => l?.code + ); + const target = + targetField === "en-US" + ? targetField + : await findFuzzyLanguage(targetField, "target").then( + (l) => l?.code + ); + + if (source === undefined) { + abort("Source language not found"); + } + if (!target) { + abort("Target language not found"); + } + + const { text, model } = await ocrImpl(imageUrl); + const payload = await translateImpl(text, source, target, model); + await interaction.editReply(payload); + }); }, }); diff --git a/src/commands/utility/httpcat.ts b/src/commands/utility/httpcat.ts index 9da8321..60152a1 100644 --- a/src/commands/utility/httpcat.ts +++ b/src/commands/utility/httpcat.ts @@ -1,6 +1,14 @@ import { SlashCommandBuilder } from "discord.js"; import { defineCommand } from ".."; import { STATUS_CODES } from "http"; +import Fuse from "fuse.js"; + +const codes = Object.entries(STATUS_CODES).map(([code, reason]) => ({ + code, + reason, +})); + +const fuzzyCodes = new Fuse(codes, { keys: ["code", "reason"] }); export default defineCommand({ data: new SlashCommandBuilder() @@ -15,14 +23,31 @@ export default defineCommand({ ), async autocomplete(interaction) { + const value = interaction.options.getFocused(); + if (!value) { + await interaction.respond( + codes + .map(({ code, reason }) => ({ + name: `${code} - ${reason}`, + value: +code, + })) + .slice(0, 25) + ); + return; + } else if (STATUS_CODES[value]) { + await interaction.respond([ + { name: `${value} - ${STATUS_CODES[value]}`, value: +value }, + ]); + return; + } + await interaction.respond( - Object.keys(STATUS_CODES) - .filter((code) => - code.startsWith( - interaction.options.getInteger("code", true).toString() - ) - ) - .map((code) => ({ name: code, value: +code })) + fuzzyCodes + .search(interaction.options.getFocused()) + .map(({ item }) => ({ + name: `${item.code} - ${item.reason}`, + value: +item.code, + })) .slice(0, 25) ); }, diff --git a/src/utils/deepl.ts b/src/utils/deepl.ts index 17ed504..994f376 100644 --- a/src/utils/deepl.ts +++ b/src/utils/deepl.ts @@ -1,16 +1,43 @@ import { Translator, + type Language, type SourceLanguageCode, type TargetLanguageCode, } from "deepl-node"; import { env } from "../env"; import { lazy } from "./functions"; import type { TranslateResult } from "../types/translate"; +import Fuse from "fuse.js"; const translator = new Translator(env.DEEPL_API_KEY); export const getSourceLanguages = lazy(() => translator.getSourceLanguages()); export const getTargetLanguages = lazy(() => translator.getTargetLanguages()); +const fuzzyLanguages = lazy(async () => { + const keys = ["name", "code"]; + return { + source: new Fuse(await getSourceLanguages(), { keys }), + target: new Fuse(await getTargetLanguages(), { keys }), + }; +}); + +export async function searchFuzzyLanguages( + query: string, + type: "source" | "target" +) { + const { source, target } = await fuzzyLanguages(); + const fuse = type === "source" ? source : target; + return fuse.search(query); +} + +export async function findFuzzyLanguage( + query: string, + type: "source" | "target" +): Promise { + const results = await searchFuzzyLanguages(query, type); + return results[0]?.item; +} + export async function translate( text: string, source: string | null = null,