fix dm commands, add url support for ocr

This commit is contained in:
artie 2025-02-11 18:41:10 +01:00
parent 5842a9911c
commit c194de0c9b
12 changed files with 139 additions and 61 deletions

View File

@ -7,7 +7,7 @@
"dev": "bun --watch src/index.ts", "dev": "bun --watch src/index.ts",
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"sync": "bun run src/scripts/sync.ts", "sync": "bun run src/scripts/sync.ts",
"test": "bun run src/scripts/test.ts", "sandbox": "bun run src/scripts/sandbox.ts",
"deploy": "src/scripts/deploy.sh" "deploy": "src/scripts/deploy.sh"
}, },
"dependencies": { "dependencies": {

View File

@ -1,4 +1,10 @@
import { Client, Collection, GatewayIntentBits, REST } from "discord.js"; import {
Client,
Collection,
GatewayIntentBits,
Partials,
REST,
} from "discord.js";
import { env } from "./env"; import { env } from "./env";
import { ActivityType, API } from "@discordjs/core"; import { ActivityType, API } from "@discordjs/core";
import type { Command } from "./types/command"; import type { Command } from "./types/command";
@ -28,6 +34,7 @@ export class ArtemisClient extends Client {
presence: { presence: {
activities: [{ name: "🩷", type: ActivityType.Custom }], activities: [{ name: "🩷", type: ActivityType.Custom }],
}, },
partials: [Partials.Channel],
}); });
const rest = new REST().setToken(env.DISCORD_TOKEN); const rest = new REST().setToken(env.DISCORD_TOKEN);

View File

@ -1,6 +1,6 @@
import { import {
Attachment,
AutocompleteInteraction, AutocompleteInteraction,
hyperlink,
SlashCommandBuilder, SlashCommandBuilder,
type InteractionEditReplyOptions, type InteractionEditReplyOptions,
} from "discord.js"; } from "discord.js";
@ -39,7 +39,7 @@ export async function translateImpl(
text: string, text: string,
source: string | null, source: string | null,
target: string, target: string,
attachment?: Attachment imageUrl?: string
): Promise<InteractionEditReplyOptions> { ): Promise<InteractionEditReplyOptions> {
const { const {
text: translatedText, text: translatedText,
@ -56,6 +56,7 @@ export async function translateImpl(
if (translatedText.length > 4096) { if (translatedText.length > 4096) {
return { return {
content: imageUrl ? `${hyperlink("Image", imageUrl)}\n\n` : undefined,
files: [ files: [
{ {
name: `${displaySource}-${displayTarget}.txt`, name: `${displaySource}-${displayTarget}.txt`,
@ -63,7 +64,6 @@ export async function translateImpl(
`--- From ${displaySource} to ${displayTarget} ---\n${translatedText}` `--- From ${displaySource} to ${displayTarget} ---\n${translatedText}`
), ),
}, },
...(attachment ? [attachment] : []),
], ],
}; };
} }
@ -74,6 +74,7 @@ export async function translateImpl(
title: `From ${displaySource} to ${displayTarget}`, title: `From ${displaySource} to ${displayTarget}`,
description: translatedText, description: translatedText,
color: 0x0f2b46, color: 0x0f2b46,
...(imageUrl ? { image: { url: imageUrl } } : {}),
author: { author: {
name: "DeepL", name: "DeepL",
icon_url: "https://www.google.com/s2/favicons?domain=deepl.com&sz=64", icon_url: "https://www.google.com/s2/favicons?domain=deepl.com&sz=64",
@ -83,7 +84,6 @@ export async function translateImpl(
}, },
}, },
], ],
files: attachment ? [attachment] : [],
}; };
} }

View File

@ -1,6 +1,7 @@
import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js"; import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js";
import { defineCommand } from ".."; import { defineCommand } from "..";
import { translateImpl } from "./translate"; import { translateImpl } from "./translate";
import { abort } from "../../utils/error";
export default defineCommand({ export default defineCommand({
data: new ContextMenuCommandBuilder() data: new ContextMenuCommandBuilder()
@ -11,6 +12,8 @@ export default defineCommand({
if (!interaction.isMessageContextMenuCommand()) return; if (!interaction.isMessageContextMenuCommand()) return;
const text = interaction.targetMessage.content; const text = interaction.targetMessage.content;
if (!text) abort("No text to translate");
await interaction.deferReply(); await interaction.deferReply();
const payload = await translateImpl(text, null, "en-US"); const payload = await translateImpl(text, null, "en-US");

View File

@ -1,6 +1,6 @@
import { import {
Attachment,
codeBlock, codeBlock,
hyperlink,
inlineCode, inlineCode,
SlashCommandBuilder, SlashCommandBuilder,
type InteractionEditReplyOptions, type InteractionEditReplyOptions,
@ -10,41 +10,73 @@ import { downloadFile } from "../../utils/http";
import { abort } from "../../utils/error"; import { abort } from "../../utils/error";
import { yandexOcr } from "../../utils/yandex"; import { yandexOcr } from "../../utils/yandex";
import sharp from "sharp"; import sharp from "sharp";
import { getImageFromAttachmentOrString, run } from "../../utils/functions";
export function buildOcrPayload( export function buildOcrPayload(
text: string, text: string,
detected_lang: string, detected_lang: string,
attachment?: Attachment imageUrl?: string
): InteractionEditReplyOptions { ): InteractionEditReplyOptions {
const languageName = const languageName = run(() => {
try {
return (
new Intl.DisplayNames(["en"], { type: "language" }).of(detected_lang) ?? new Intl.DisplayNames(["en"], { type: "language" }).of(detected_lang) ??
"unknown"; "unknown"
);
} catch {
return "unknown";
}
});
const content = `Detected language: ${inlineCode(languageName)}\n${codeBlock( const content =
text `Detected language: ${inlineCode(languageName)}\n${codeBlock(text)}` +
)}`; (imageUrl ? `\n${hyperlink("Image", imageUrl)}` : "");
if (content.length > 2000) { if (content.length > 4096) {
return { return {
content: `Detected language: ${inlineCode(languageName)}`, content:
`Detected language: ${inlineCode(languageName)}` +
(imageUrl ? `\n${hyperlink("Image", imageUrl)}` : ""),
files: [ files: [
{ {
name: "ocr.txt", name: "ocr.txt",
attachment: Buffer.from(text), attachment: Buffer.from(text),
}, },
...(attachment ? [attachment] : []),
], ],
}; };
} }
return { return {
content, embeds: [
files: attachment ? [attachment] : [], {
description: codeBlock(text),
color: 0xffdb4d,
fields: [
{
name: "Detected language",
value: inlineCode(languageName),
},
],
...(imageUrl ? { image: { url: imageUrl } } : {}),
author: {
name: "Yandex",
icon_url:
"https://www.google.com/s2/favicons?domain=yandex.com&sz=64",
},
},
],
}; };
} }
export async function ocrImpl(attachment: Attachment) { export async function ocrImpl(url: string) {
const { data, type } = await downloadFile(attachment.url); const { data, type } = await run(async () => {
try {
return await downloadFile(url);
} catch {
abort("Failed to download the image");
}
});
if (!type?.mime.startsWith("image/")) { if (!type?.mime.startsWith("image/")) {
abort("The file must be an image!"); abort("The file must be an image!");
} }
@ -54,7 +86,12 @@ export async function ocrImpl(attachment: Attachment) {
.jpeg({ quality: 90 }) .jpeg({ quality: 90 })
.toBuffer(); .toBuffer();
return yandexOcr(compressed, type.mime); const result = await yandexOcr(compressed, type.mime);
if (!result.text) {
result.text = "No text detected";
}
return result;
} }
export default defineCommand({ export default defineCommand({
@ -62,25 +99,24 @@ export default defineCommand({
.setName("ocr") .setName("ocr")
.setDescription("OCR an image using Yandex") .setDescription("OCR an image using Yandex")
.addAttachmentOption((option) => .addAttachmentOption((option) =>
option option.setName("image").setDescription("The image to OCR")
.setName("image") )
.setDescription("The image to OCR") .addStringOption((option) =>
.setRequired(true) option.setName("url").setDescription("The image URL to OCR")
), ),
async execute(interaction) { async execute(interaction) {
const attachment = interaction.options.getAttachment("image", true); const attachment = interaction.options.getAttachment("image");
if (!attachment.contentType?.startsWith("image/")) { const url = interaction.options.getString("url");
abort("The file must be an image!"); const imageUrl = getImageFromAttachmentOrString(attachment, url);
}
await interaction.deferReply(); await interaction.deferReply();
const result = await ocrImpl(attachment); const result = await ocrImpl(imageUrl);
const payload = buildOcrPayload( const payload = buildOcrPayload(
result.text, result.text,
result.detected_lang, result.detected_lang,
attachment imageUrl
); );
await interaction.editReply(payload); await interaction.editReply(payload);
}, },

View File

@ -1,7 +1,7 @@
import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js"; import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js";
import { defineCommand } from ".."; import { defineCommand } from "..";
import { abort } from "../../utils/error";
import { buildOcrPayload, ocrImpl } from "./ocr"; import { buildOcrPayload, ocrImpl } from "./ocr";
import { getImageFromAttachmentOrString } from "../../utils/functions";
export default defineCommand({ export default defineCommand({
data: new ContextMenuCommandBuilder() data: new ContextMenuCommandBuilder()
@ -12,16 +12,14 @@ export default defineCommand({
if (!interaction.isMessageContextMenuCommand()) return; if (!interaction.isMessageContextMenuCommand()) return;
const attachment = interaction.targetMessage.attachments.first(); const attachment = interaction.targetMessage.attachments.first();
if (!attachment) { const imageUrl = getImageFromAttachmentOrString(
abort("No attachment found"); attachment,
} interaction.targetMessage.content
if (!attachment.contentType?.startsWith("image/")) { );
abort("The file must be an image!");
}
await interaction.deferReply(); await interaction.deferReply();
const result = await ocrImpl(attachment); const result = await ocrImpl(imageUrl);
const payload = buildOcrPayload(result.text, result.detected_lang); const payload = buildOcrPayload(result.text, result.detected_lang);
await interaction.editReply(payload); await interaction.editReply(payload);
}, },

View File

@ -10,6 +10,7 @@ import {
translateImpl, translateImpl,
} from "../language/translate"; } from "../language/translate";
import { ocrImpl } from "./ocr"; import { ocrImpl } from "./ocr";
import { getImageFromAttachmentOrString } from "../../utils/functions";
export default defineCommand({ export default defineCommand({
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
@ -18,10 +19,10 @@ export default defineCommand({
"OCR an image using Yandex and translate the result using DeepL" "OCR an image using Yandex and translate the result using DeepL"
) )
.addAttachmentOption((option) => .addAttachmentOption((option) =>
option option.setName("image").setDescription("The image to OCR")
.setName("image") )
.setDescription("The image to OCR") .addStringOption((option) =>
.setRequired(true) option.setName("url").setDescription("The image URL to OCR")
) )
.addStringOption((option) => .addStringOption((option) =>
option option
@ -39,13 +40,13 @@ export default defineCommand({
autocomplete: translateAutocompleteImpl, autocomplete: translateAutocompleteImpl,
async execute(interaction) { async execute(interaction) {
const attachment = interaction.options.getAttachment("image", true); const attachment = interaction.options.getAttachment("image");
const url = interaction.options.getString("url");
const source = interaction.options.getString("source") ?? null; const source = interaction.options.getString("source") ?? null;
const target = interaction.options.getString("target") ?? "en-US"; const target = interaction.options.getString("target") ?? "en-US";
if (!attachment.contentType?.startsWith("image/")) { const imageUrl = getImageFromAttachmentOrString(attachment, url);
abort("The file must be an image!");
}
await interaction.deferReply(); await interaction.deferReply();
@ -56,8 +57,8 @@ export default defineCommand({
abort("Target language not supported"); abort("Target language not supported");
} }
const { text } = await ocrImpl(attachment); const { text } = await ocrImpl(imageUrl);
const payload = await translateImpl(text, source, target, attachment); const payload = await translateImpl(text, source, target, imageUrl);
await interaction.editReply(payload); await interaction.editReply(payload);
}, },
}); });

View File

@ -1,8 +1,8 @@
import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js"; import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js";
import { defineCommand } from ".."; import { defineCommand } from "..";
import { abort } from "../../utils/error";
import { translateImpl } from "../language/translate"; import { translateImpl } from "../language/translate";
import { ocrImpl } from "./ocr"; import { ocrImpl } from "./ocr";
import { getImageFromAttachmentOrString } from "../../utils/functions";
export default defineCommand({ export default defineCommand({
data: new ContextMenuCommandBuilder() data: new ContextMenuCommandBuilder()
@ -13,16 +13,14 @@ export default defineCommand({
if (!interaction.isMessageContextMenuCommand()) return; if (!interaction.isMessageContextMenuCommand()) return;
const attachment = interaction.targetMessage.attachments.first(); const attachment = interaction.targetMessage.attachments.first();
if (!attachment) { const imageUrl = getImageFromAttachmentOrString(
abort("No attachment found"); attachment,
} interaction.targetMessage.content
if (!attachment.contentType?.startsWith("image/")) { );
abort("The file must be an image!");
}
await interaction.deferReply(); await interaction.deferReply();
const { text } = await ocrImpl(attachment); const { text } = await ocrImpl(imageUrl);
const payload = await translateImpl(text, null, "en-US"); const payload = await translateImpl(text, null, "en-US");
await interaction.editReply(payload); await interaction.editReply(payload);
}, },

6
src/scripts/sandbox.ts Normal file
View File

@ -0,0 +1,6 @@
import { findFirstUrl } from "../utils/functions";
const text =
"I ended up doing https://cdn.discordapp.com/attachments/338689901111541760/1338884762785288253/image.png?ex=67acb51a&is=67ab639a&hm=92ed060dabffccf9544157da2922bce79386eca444a182db92f19d833d66fba6&b because yah";
console.log(findFirstUrl(text));

View File

@ -1,3 +0,0 @@
import { getWikipediaEditions } from "../utils/wikipedia";
console.log(await getWikipediaEditions());

View File

@ -8,6 +8,9 @@ export const USER_AGENT = `artemis (discord.js ${version})`;
export const FAKE_USER_AGENT = export const FAKE_USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0"; "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0";
export const URL_REGEX =
/\bhttps?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)\b/gi;
export const GOOD_BOT_EMOJIS = [ export const GOOD_BOT_EMOJIS = [
"<:teehee:825098257742299136>", "<:teehee:825098257742299136>",
"<:teehee2:825098258741067787>", "<:teehee2:825098258741067787>",

View File

@ -1,6 +1,9 @@
import * as cheerio from "cheerio"; import * as cheerio from "cheerio";
import { execa } from "execa"; import { execa } from "execa";
import { customAlphabet } from "nanoid"; import { customAlphabet } from "nanoid";
import { URL_REGEX } from "./constants";
import type { Attachment } from "discord.js";
import { abort } from "./error";
export const nanoid = customAlphabet("1234567890abcdef"); export const nanoid = customAlphabet("1234567890abcdef");
export const shell = execa({ reject: false }); export const shell = execa({ reject: false });
@ -63,3 +66,29 @@ export function lazy<T>(cb: () => T) {
export function trim(str: string, maxLength: number) { export function trim(str: string, maxLength: number) {
return str.length > maxLength ? str.slice(0, maxLength) + "…" : str; return str.length > maxLength ? str.slice(0, maxLength) + "…" : str;
} }
export function findUrls(text: string) {
return text.match(URL_REGEX) ?? [];
}
export function findFirstUrl(text: string) {
return findUrls(text)[0];
}
export function getImageFromAttachmentOrString(
attachment?: Attachment | null,
str?: string | null
) {
if (attachment) {
if (!attachment.contentType?.startsWith("image/")) {
abort("The file must be an image!");
}
return attachment.url;
} else if (str) {
const match = findFirstUrl(str);
if (!match) abort("The URL is invalid!");
return match;
} else {
abort("You must provide an image or an image URL!");
}
}