generated from nhcarrigan/template
feat: initial project prototype #1
38
.gitea/workflows/ci.yml
Normal file
38
.gitea/workflows/ci.yml
Normal 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
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
node_modules
|
||||
prod
|
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.validate": ["typescript"]
|
||||
}
|
18
README.md
18
README.md
@ -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
5
eslint.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
import NaomisConfig from "@nhcarrigan/eslint-config";
|
||||
|
||||
export default [
|
||||
...NaomisConfig
|
||||
]
|
28
package.json
Normal file
28
package.json
Normal 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
4676
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
prod.env
Normal file
2
prod.env
Normal 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
57
src/commands/translate.ts
Normal 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
58
src/config/locales.ts
Normal 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
26
src/index.ts
Normal 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
24
src/modules/getLocale.ts
Normal 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
72
src/modules/translate.ts
Normal 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
71
src/server/serve.ts
Normal 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
31
src/utils/logHandler.ts
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "@nhcarrigan/typescript-config",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./prod"
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user