feat: initial project prototype (#1)
Some checks failed
Node.js CI / Lint and Test (push) Has been cancelled

### Explanation

_No response_

### Issue

_No response_

### Attestations

- [x] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/)
- [x] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/).
- [x] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/).

### Dependencies

- [x] I have pinned the dependencies to a specific patch version.

### Style

- [x] I have run the linter and resolved any errors.
- [x] My pull request uses an appropriate title, matching the conventional commit standards.
- [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request.

### Tests

- [ ] My contribution adds new code, and I have added tests to cover it.
- [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes.
- [ ] All new and existing tests pass locally with my changes.
- [ ] Code coverage remains at or above the configured threshold.

### Documentation

_No response_

### Versioning

Major - My pull request introduces a breaking change.

Reviewed-on: #1
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit is contained in:
2025-02-10 14:33:27 -08:00
committed by Naomi Carrigan
parent 17a78effeb
commit 7e5a0ada2a
45 changed files with 10126 additions and 14 deletions

57
src/commands/translate.ts Normal file
View File

@ -0,0 +1,57 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
ApplicationCommandType,
ApplicationIntegrationType,
ContextMenuCommandBuilder,
InteractionContextType,
Locale,
} from "discord.js";
const command = new ContextMenuCommandBuilder().
setContexts(
InteractionContextType.BotDM,
InteractionContextType.Guild,
InteractionContextType.PrivateChannel,
).
setIntegrationTypes(ApplicationIntegrationType.UserInstall).
setType(ApplicationCommandType.Message).
setName("Translate Message").
setNameLocalizations({
[Locale.Indonesian]: "Terjemahkan Pesan",
[Locale.EnglishGB]: "Translate Message",
[Locale.EnglishUS]: "Translate Message",
[Locale.Bulgarian]: "Предаване на съобщение",
[Locale.ChineseCN]: "翻译信件",
[Locale.ChineseTW]: "翻譯訊息",
[Locale.Czech]: "Přeložit zprávu",
[Locale.Danish]: "Oversæt brev",
[Locale.Dutch]: "Bericht vertalen",
[Locale.Finnish]: "Käännä viesti",
[Locale.French]: "Traduire le message",
[Locale.German]: "Nachricht übersetzen",
[Locale.Greek]: "Μετάφραση μηνύματος",
[Locale.Hindi]: "संदेश अनुवाद",
[Locale.Hungarian]: "Üzenet fordítása",
[Locale.Italian]: "Traduci il messaggio",
[Locale.Japanese]: "メッセージの翻訳",
[Locale.Korean]: "자주 묻는 질문",
[Locale.Lithuanian]: "Tulkot ziņojumu",
[Locale.Polish]: "Przetłumacz wiadomość",
[Locale.PortugueseBR]: "Traduzir mensagem",
[Locale.Romanian]: "Tradu mesajul",
[Locale.Russian]: "Перевод сообщения",
[Locale.SpanishES]: "Traducir mensaje",
[Locale.SpanishLATAM]: "Traducir mensaje",
[Locale.Swedish]: "Översätt meddelande",
[Locale.Thai]: "แปลจดหมาย",
[Locale.Turkish]: "Mesajı Çevir",
[Locale.Ukrainian]: "Переклади",
});
// eslint-disable-next-line no-console -- We don't need our logger here as this never runs in production.
console.log(JSON.stringify(command.toJSON()));

58
src/config/locales.ts Normal file
View File

@ -0,0 +1,58 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Locale } from "discord.js";
/**
* List of locales that LibreTranslate supports. This is a mix of
* Discord locales and mapped values.
*/
const supportedLocales: Array<string> = [
Locale.Indonesian,
"en",
Locale.Bulgarian,
"zh",
"zt",
Locale.Czech,
Locale.Danish,
Locale.Dutch,
Locale.Finnish,
Locale.French,
Locale.German,
Locale.Greek,
Locale.Hindi,
Locale.Hungarian,
Locale.Italian,
Locale.Japanese,
Locale.Korean,
Locale.Lithuanian,
Locale.Polish,
"pt",
Locale.Romanian,
Locale.Russian,
"es",
"sv",
Locale.Thai,
Locale.Turkish,
Locale.Ukrainian,
];
/**
* Maps a Discord locale string to the corresponding LibreTranslate
* locale when they are different.
*/
const mappedLocales: Partial<Record<Locale, string>> = {
[Locale.EnglishGB]: "en",
[Locale.EnglishUS]: "en",
[Locale.ChineseCN]: "zh",
[Locale.ChineseTW]: "zt",
[Locale.PortugueseBR]: "pt",
[Locale.SpanishES]: "es",
[Locale.SpanishLATAM]: "es",
[Locale.Swedish]: "sv",
};
export { supportedLocales, mappedLocales };

218
src/i18n/responses.ts Normal file
View File

@ -0,0 +1,218 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- This is the convention for these keys. */
/* eslint-disable stylistic/max-len -- These are going to be long strings and that's okay. */
import { Locale } from "discord.js";
export const responses: Record<string, { "no-message-content": string; "subscription-required": string; "translation": string; "unsupported-locale": string }> = {
en: {
"no-message-content": "No message content found.",
"subscription-required":
"You must be subscribed to translate messages.",
"translation":
"{{translation}}\n-# Detected {{language}} with {{confidence}}% confidence.",
"unsupported-locale": "Language {{target}} is not supported by our translation software.",
},
[Locale.Indonesian]: {
"no-message-content": "Tidak ada konten pesan ditemukan.",
"subscription-required":
"Anda harus berlangganan untuk menerjemahkan pesan.",
"translation":
"{{translation}}\n-# Mendeteksi {{language}} dengan kepercayaan {{confidence}}%.",
"unsupported-locale": "Bahasa {{target}} tidak didukung oleh perangkat lunak terjemahan kami.",
},
es: {
"no-message-content": "No se encontró contenido del mensaje.",
"subscription-required":
"Debes estar suscrito para traducir mensajes.",
"translation":
"{{translation}}\n-# Detectado {{language}} con {{confidence}}% de confianza.",
"unsupported-locale": "El idioma {{target}} no es compatible con nuestro software de traducción.",
},
pt: {
"no-message-content": "Nenhum conteúdo de mensagem encontrado.",
"subscription-required":
"Você deve estar inscrito para traduzir mensagens.",
"translation":
"{{translation}}\n-# Detectado {{language}} com {{confidence}}% de confiança.",
"unsupported-locale": "O idioma {{target}} não é suportado pelo nosso software de tradução.",
},
[Locale.Czech]: {
"no-message-content": "Nebyl nalezen žádný obsah zprávy.",
"subscription-required":
"Musíte být přihlášeni k překladu zpráv.",
"translation":
"{{translation}}\n-# Detekováno {{language}} s důvěrou {{confidence}}%.",
"unsupported-locale": "Jazyk {{target}} není podporován naším překladovým softwarem.",
},
[Locale.Danish]: {
"no-message-content": "Ingen beskedindhold fundet.",
"subscription-required":
"Du skal være tilmeldt for at oversætte beskeder.",
"translation":
"{{translation}}\n-# Detekteret {{language}} med {{confidence}}% tillid.",
"unsupported-locale": "Sproget {{target}} understøttes ikke af vores oversættelsessoftware.",
},
[Locale.Dutch]: {
"no-message-content": "Geen berichtinhoud gevonden.",
"subscription-required":
"U moet zijn geabonneerd om berichten te vertalen.",
"translation":
"{{translation}}\n-# Gedetecteerd {{language}} met {{confidence}}% vertrouwen.",
"unsupported-locale": "Taal {{target}} wordt niet ondersteund door onze vertaalsoftware.",
},
[Locale.Finnish]: {
"no-message-content": "Ei viestisisältöä löytynyt.",
"subscription-required":
"Sinun on tilattava viestien kääntämiseksi.",
"translation":
"{{translation}}\n-# Havaittu {{language}} {{confidence}}% luottamuksella.",
"unsupported-locale": "Kieltä {{target}} ei tueta käännössovelluksellamme.",
},
[Locale.French]: {
"no-message-content": "Aucun contenu de message trouvé.",
"subscription-required":
"Vous devez être abonné pour traduire les messages.",
"translation":
"{{translation}}\n-# Détecté {{language}} avec {{confidence}}% de confiance.",
"unsupported-locale": "La langue {{target}} n'est pas prise en charge par notre logiciel de traduction.",
},
[Locale.German]: {
"no-message-content": "Kein Nachrichteninhalt gefunden.",
"subscription-required":
"Sie müssen abonniert sein, um Nachrichten zu übersetzen.",
"translation":
"{{translation}}\n-# Erkannt {{language}} mit {{confidence}}% Vertrauen.",
"unsupported-locale": "Die Sprache {{target}} wird von unserer Übersetzungssoftware nicht unterstützt.",
},
[Locale.Greek]: {
"no-message-content": "Δεν βρέθηκε περιεχόμενο μηνύματος.",
"subscription-required":
"Πρέπει να είστε συνδρομητής για να μεταφράσετε μηνύματα.",
"translation":
"{{translation}}\n-# Ανιχνεύθηκε {{language}} με {{confidence}}% εμπιστοσύνη.",
"unsupported-locale": "Η γλώσσα {{target}} δεν υποστηρίζεται από το λογισμικό μετάφρασής μας.",
},
[Locale.Hindi]: {
"no-message-content": "कोई संदेश सामग्री नहीं मिली।",
"subscription-required":
"आपको संदेश अनुवाद करने के लिए सब्सक्राइब करना होगा।",
"translation":
"{{translation}}\n-# {{confidence}}% विश्वास के साथ {{language}} का पता लगाया गया।",
"unsupported-locale": "हमारे अनुवाद सॉफ़्टवेयर द्वारा {{target}} भाषा का समर्थन नहीं किया जाता है।",
},
[Locale.Hungarian]: {
"no-message-content": "Nem található üzenettartalom.",
"subscription-required":
"Fel kell iratkoznia az üzenetek fordításához.",
"translation":
"{{translation}}\n-# {{language}} érzékelve {{confidence}}% bizalommal.",
"unsupported-locale": "A {{target}} nyelvet nem támogatja fordító szoftverünk.",
},
[Locale.Italian]: {
"no-message-content": "Nessun contenuto del messaggio trovato.",
"subscription-required":
"Devi essere abbonato per tradurre i messaggi.",
"translation":
"{{translation}}\n-# Rilevato {{language}} con {{confidence}}% di fiducia.",
"unsupported-locale": "La lingua {{target}} non è supportata dal nostro software di traduzione.",
},
[Locale.Japanese]: {
"no-message-content": "メッセージコンテンツが見つかりません。",
"subscription-required":
"メッセージを翻訳するには購読する必要があります。",
"translation":
"{{translation}}\n-# {{confidence}}% の信頼度で {{language}} を検出しました。",
"unsupported-locale": "{{target}} 言語は、翻訳ソフトウェアでサポートされていません。",
},
[Locale.Korean]: {
"no-message-content": "메시지 내용을 찾을 수 없습니다.",
"subscription-required":
"메시지를 번역하려면 구독해야 합니다.",
"translation":
"{{translation}}\n-# {{confidence}}% 신뢰도로 {{language}} 감지.",
"unsupported-locale": "{{target}} 언어는 번역 소프트웨어에서 지원되지 않습니다.",
},
[Locale.Lithuanian]: {
"no-message-content": "Nerasta jokio pranešimo turinio.",
"subscription-required":
"Norint išversti žinutes, turite būti prenumeratorius.",
"translation":
"{{translation}}\n-# Aptikta {{language}} su {{confidence}}% pasitikėjimu.",
"unsupported-locale": "{{target}} kalba nepalaikoma mūsų vertimo programine įranga.",
},
[Locale.Polish]: {
"no-message-content": "Nie znaleziono treści wiadomości.",
"subscription-required":
"Aby tłumaczyć wiadomości, musisz być subskrybentem.",
"translation":
"{{translation}}\n-# Wykryto {{language}} z {{confidence}}% pewnością.",
"unsupported-locale": "Język {{target}} nie jest obsługiwany przez nasze oprogramowanie do tłumaczenia.",
},
sv: {
"no-message-content": "Inget meddelandeinnehåll hittades.",
"subscription-required":
"Du måste prenumerera för att översätta meddelanden.",
"translation":
"{{translation}}\n-# Upptäckt {{language}} med {{confidence}}% förtroende.",
"unsupported-locale": "Språket {{target}} stöds inte av vår översättningsprogramvara.",
},
[Locale.Romanian]: {
"no-message-content": "Nu s-a găsit niciun conținut de mesaj.",
"subscription-required":
"Trebuie să fiți abonat pentru a traduce mesajele.",
"translation":
"{{translation}}\n-# Detectat {{language}} cu {{confidence}}% încredere.",
"unsupported-locale": "Limba {{target}} nu este acceptată de software-ul nostru de traducere.",
},
[Locale.Russian]: {
"no-message-content": "Содержимое сообщения не найдено.",
"subscription-required":
"Вы должны быть подписаны на перевод сообщений.",
"translation":
"{{translation}}\n-# Обнаружен {{language}} с {{confidence}}% уверенностью.",
"unsupported-locale": "Язык {{target}} не поддерживается нашим программным обеспечением для перевода.",
},
zh: {
"no-message-content": "未找到消息内容。",
"subscription-required": "您必须订阅以翻译消息。",
"translation":
"{{translation}}\n-# 检测到 {{language}}{{confidence}}% 的信心。",
"unsupported-locale": "我们的翻译软件不支持 {{target}} 语言。",
},
zt: {
"no-message-content": "未找到消息内容。",
"subscription-required": "您必须订阅以翻译消息。",
"translation":
"{{translation}}\n-# 检测到 {{language}}{{confidence}}% 的信心。",
"unsupported-locale": "我们的翻译软件不支持 {{target}} 语言。",
},
[Locale.Thai]: {
"no-message-content": "ไม่พบเนื้อหาข้อความ",
"subscription-required":
"คุณต้องสมัครสมาชิกเพื่อแปลข้อความ",
"translation":
"{{translation}}\n-# ตรวจพบ {{language}} ด้วยความมั่นใจ {{confidence}}%",
"unsupported-locale": "ภาษา {{target}} ไม่ได้รับการสนับสนุนโดยซอฟต์แวร์แปลของเรา",
},
[Locale.Turkish]: {
"no-message-content": "Hiçbir mesaj içeriği bulunamadı.",
"subscription-required":
"Mesajları çevirmek için abone olmalısınız.",
"translation":
"{{translation}}\n-# {{confidence}}% güvenle {{language}} tespit edildi.",
"unsupported-locale": "{{target}} dilimiz tarafından desteklenmiyor.",
},
[Locale.Ukrainian]: {
"no-message-content": "Не знайдено вмісту повідомлення.",
"subscription-required":
"Ви повинні підписатися на переклад повідомлень.",
"translation":
"{{translation}}\n-# Виявлено {{language}} з {{confidence}}% впевненістю.",
"unsupported-locale": "Мова {{target}} не підтримується нашим програмним забезпеченням для перекладу.",
},
};

26
src/index.ts Normal file
View File

@ -0,0 +1,26 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Client, Events } from "discord.js";
import { translate } from "./modules/translate.js";
import { instantiateServer } from "./server/serve.js";
import { logHandler } from "./utils/logHandler.js";
const client = new Client({
intents: [],
});
client.on(Events.InteractionCreate, (interaction) => {
if (interaction.isMessageContextMenuCommand()) {
void translate(interaction);
}
});
client.on(Events.ClientReady, () => {
logHandler.info("Bot is ready.");
});
instantiateServer();
await client.login(process.env.DISCORD_TOKEN);

24
src/modules/getLocale.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { mappedLocales } from "../config/locales.js";
import type { MessageContextMenuCommandInteraction } from "discord.js";
/**
* Parses the locale from the interaction, using our mapped
* values to match with LibreTranslate where necessary.
* @param interaction -- The interaction payload from Discord.
* @returns The locale string.
*/
export const getLocale = (
interaction: MessageContextMenuCommandInteraction,
): string => {
if (mappedLocales[interaction.locale] !== undefined) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- It's not undefined.
return mappedLocales[interaction.locale] as string;
}
return interaction.locale;
};

94
src/modules/translate.ts Normal file
View File

@ -0,0 +1,94 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
MessageFlags,
type MessageContextMenuCommandInteraction,
} from "discord.js";
import { supportedLocales } from "../config/locales.js";
import { i18n } from "../utils/i18n.js";
import { getLocale } from "./getLocale.js";
/**
* Translates a message to the user's locale.
* @param interaction -- The interaction payload from Discord.
*/
// eslint-disable-next-line max-statements, max-lines-per-function -- This is a complex function.
export const translate = async(
interaction: MessageContextMenuCommandInteraction,
): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const targetLocale = getLocale(interaction);
const isEntitled = interaction.entitlements.find((entitlement) => {
return entitlement.userId === interaction.user.id && entitlement.isActive();
});
if (!isEntitled) {
await interaction.editReply({
content: i18n("subscription-required", targetLocale),
});
return;
}
if (!supportedLocales.includes(targetLocale)) {
await interaction.editReply(i18n("unsupported-locale", targetLocale, {
target: targetLocale,
}));
return;
}
const message = interaction.options.getMessage("message", true);
if (message.content === "") {
await interaction.editReply({
content: i18n("no-message-content", targetLocale),
});
return;
}
const sourceLocaleRequestParameters = new URLSearchParams();
sourceLocaleRequestParameters.append("q", message.content);
sourceLocaleRequestParameters.append(
"api_key",
process.env.TRANSLATE_TOKEN ?? "",
);
const sourceLocaleRequest = await fetch(
"https://trans.nhcarrigan.com/detect",
{
body: sourceLocaleRequestParameters,
method: "POST",
},
);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- .json() doesn't accept a generic.
const [ sourceLocale ] = (await sourceLocaleRequest.json()) as Array<{
confidence: number;
language: string;
}>;
const translationRequestParameters = new URLSearchParams();
translationRequestParameters.append("q", message.content);
translationRequestParameters.append("source", sourceLocale?.language ?? "en");
translationRequestParameters.append("target", targetLocale);
translationRequestParameters.append(
"api_key",
process.env.TRANSLATE_TOKEN ?? "",
);
const translationRequest = await fetch(
"https://trans.nhcarrigan.com/translate",
{ body: translationRequestParameters, method: "POST" },
);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- .json() doesn't accept a generic.
const translation = (await translationRequest.json()) as {
translatedText: string;
};
await interaction.editReply({
content: i18n("translation", targetLocale, {
confidence: sourceLocale?.confidence,
language: sourceLocale?.language,
lng: targetLocale,
translation: translation.translatedText,
}),
});
};

71
src/server/serve.ts Normal file
View File

@ -0,0 +1,71 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import fastify from "fastify";
import { logHandler } from "../utils/logHandler.js";
const html = `<!DOCTYPE html>
<html>
<head>
<title>Aria Iuvo</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Bot to translate your messages on Discord!" />
<script src="https://cdn.nhcarrigan.com/headers/index.js" async defer></script>
</head>
<body>
<main>
<h1>Aria Iuvo</h1>
<section>
<p>Bot to translate your messages on Discord!</p>
</section>
<section>
<h2>Links</h2>
<p>
<a href="https://git.nhcarrigan.com/nhcarrigan/aria-iuvo">
<i class="fa-solid fa-code"></i> Source Code
</a>
</p>
<p>
<a href="https://docs.nhcarrigan.com/">
<i class="fa-solid fa-book"></i> Documentation
</a>
</p>
<p>
<a href="https://chat.nhcarrigan.com">
<i class="fa-solid fa-circle-info"></i> Support
</a>
</p>
</section>
</main>
</body>
</html>`;
/**
* Starts up a web server for health monitoring.
*/
export const instantiateServer = (): void => {
try {
const server = fastify({
logger: false,
});
server.get("/", (_request, response) => {
response.header("Content-Type", "text/html");
response.send(html);
});
server.listen({ port: 5001 }, (error) => {
if (error) {
logHandler.error(error);
return;
}
logHandler.info("Server listening on port 5001.");
});
} catch (error) {
logHandler.error(error);
}
};

28
src/utils/i18n.ts Normal file
View File

@ -0,0 +1,28 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { responses } from "../i18n/responses.js";
/**
* Translates a key to the specified locale, performing
* interpolation on the string.
* @param key -- The key to translate.
* @param locale -- The user's locale.
* @param interpolation -- An object of keys to replace with values.
* @returns The translated string.
*/
export const i18n = (
key: keyof (typeof responses)["en"],
locale: string,
interpolation: Record<string, unknown> = {},
): string => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know the en key exists, but having the loose type helps.
const string = responses[locale]?.[key] ?? responses.en![key];
// eslint-disable-next-line unicorn/no-array-reduce -- This is the cleanest way to do it, really.
return Object.entries(interpolation).reduce((accumulator, [ k, v ]) => {
return accumulator.replace(`{{${k}}}`, String(v));
}, string);
};

31
src/utils/logHandler.ts Normal file
View File

@ -0,0 +1,31 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { createLogger, format, transports, config } from "winston";
const { combine, timestamp, colorize, printf } = format;
/**
* Standard log handler, using winston to wrap and format
* messages. Call with `logHandler.log(level, message)`.
* @param {string} level - The log level to use.
* @param {string} message - The message to log.
*/
export const logHandler = createLogger({
exitOnError: false,
format: combine(
timestamp({
format: "YYYY-MM-DD HH:mm:ss",
}),
colorize(),
printf((info) => {
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions -- Winston properties...
return `${info.level}: ${info.timestamp}: ${info.message}`;
}),
),
level: "silly",
levels: config.npm.levels,
transports: [ new transports.Console() ],
});