feat: initial project prototype #1

Merged
naomi merged 5 commits from feat/init into main 2025-02-10 14:33:27 -08:00
16 changed files with 5107 additions and 14 deletions
Showing only changes of commit 88b28364c6 - Show all commits

38
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,38 @@
name: Node.js CI
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
name: Lint and Test
steps:
- name: Checkout Source Files
uses: actions/checkout@v4
- name: Use Node.js v22
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 9
- name: Install Dependencies
run: pnpm install
- name: Lint Source Files
run: pnpm run lint
- name: Verify Build
run: pnpm run build
- name: Run Tests
run: pnpm run test

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
prod

6
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,6 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.validate": ["typescript"]
}

View File

@ -1,24 +1,14 @@
# New Repository Template
# Aria Iuvo
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository.
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose.
## Readme
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
<!-- # Project Name
Project Description
Aria is a user-installable bot that allows you to translate messages anywhere on Discord.
## Live Version
This page is currently deployed. [View the live website.]
[Add her to your account](https://discord.com/oauth2/authorize?client_id=1338596130207957035)!
## Feedback and Bugs
If you have feedback or a bug report, please feel free to open a GitHub issue!
If you have feedback or a bug report, please feel free to open an issue!
## Contributing

5
eslint.config.js Normal file
View File

@ -0,0 +1,5 @@
import NaomisConfig from "@nhcarrigan/eslint-config";
export default [
...NaomisConfig
]

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "aria-iuvo",
"version": "0.0.0",
"description": "A bot to translate messages on Discord",
"main": "index.js",
"type": "module",
"scripts": {
"build": "rm -rf prod && tsc",
"lint": "eslint src --max-warnings 0",
"start": "op run --env-file=prod.env --no-masking -- node prod/index.js",
"test": "echo \"Error: no test specified\" && exit 0"
},
"keywords": [],
"author": "Naomi Carrigan",
"license": "See license in LICENSE.md",
"devDependencies": {
"@nhcarrigan/eslint-config": "5.1.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@types/node": "22.13.1",
"eslint": "9.20.0",
"typescript": "5.7.3"
},
"dependencies": {
"discord.js": "14.17.3",
"fastify": "5.2.1",
"winston": "3.17.0"
}
}

4676
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
prod.env Normal file
View File

@ -0,0 +1,2 @@
DISCORD_TOKEN="op://Environment Variables - Naomi/Aria Iuvo/discord_token"
TRANSLATE_TOKEN="op://Environment Variables - Naomi/Aria Iuvo/api_token"

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 };

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;
};

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

@ -0,0 +1,72 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
MessageFlags,
type MessageContextMenuCommandInteraction,
} from "discord.js";
import { supportedLocales } from "../config/locales.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, complexity -- This is a complex function.
export const translate = async(
interaction: MessageContextMenuCommandInteraction,
): Promise<void> => {
await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] });
const targetLocale = getLocale(interaction);
if (!supportedLocales.includes(targetLocale)) {
await interaction.editReply("Unsupported locale.");
return;
}
const message = interaction.options.getMessage("message", true);
if (message.content === "") {
await interaction.editReply("No message content found.");
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: `${translation.translatedText}\n-# Detected ${sourceLocale?.language ?? "unknown"} with ${sourceLocale?.confidence.toString() ?? "unknown"}% confidence.`,
});
};

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

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

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./prod"
}
}