From 8b607244b1f2cc46b260f7331812f5987868efe0 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Mon, 10 Feb 2025 15:43:06 -0800 Subject: [PATCH] feat: add an about command (#2) ### 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. - [x] All new and existing tests pass locally with my changes. - [x] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning Minor - My pull request introduces a new non-breaking feature. Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/aria-iuvo/pulls/2 Co-authored-by: Naomi Carrigan Co-committed-by: Naomi Carrigan --- package.json | 2 +- pnpm-lock.yaml | 46 ++-- src/commands/about.ts | 87 +++++++ src/i18n/responses.ts | 509 +++++++++++++++++++++++++++------------ src/index.ts | 4 + src/modules/about.ts | 67 ++++++ src/modules/getLocale.ts | 4 +- src/modules/translate.ts | 12 +- src/utils/i18n.ts | 45 +++- test/locales.spec.ts | 76 +++++- 10 files changed, 662 insertions(+), 190 deletions(-) create mode 100644 src/commands/about.ts create mode 100644 src/modules/about.ts diff --git a/package.json b/package.json index 92bcb33..bb95428 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "vitest": "3.0.5" }, "dependencies": { - "discord.js": "14.17.3", + "discord.js": "14.18.0", "fastify": "5.2.1", "winston": "3.17.0" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4a2fcb..3376bd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: discord.js: - specifier: 14.17.3 - version: 14.17.3 + specifier: 14.18.0 + version: 14.18.0 fastify: specifier: 5.2.1 version: 5.2.1 @@ -116,8 +116,8 @@ packages: '@dabh/diagnostics@2.0.3': resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} - '@discordjs/builders@1.10.0': - resolution: {integrity: sha512-ikVZsZP+3shmVJ5S1oM+7SveUCK3L9fTyfA8aJ7uD9cNQlTqF+3Irbk2Y22KXTb3C3RNUahRkSInClJMkHrINg==} + '@discordjs/builders@1.10.1': + resolution: {integrity: sha512-OWo1fY4ztL1/M/DUyRPShB4d/EzVfuUvPTRRHRIt/YxBrUYSz0a+JicD5F5zHFoNs2oTuWavxCOVFV1UljHTng==} engines: {node: '>=16.11.0'} '@discordjs/collection@1.5.3': @@ -132,16 +132,16 @@ packages: resolution: {integrity: sha512-YIruKw4UILt/ivO4uISmrGq2GdMY6EkoTtD0oS0GvkJFRZbTSdPhzYiUILbJ/QslsvC9H9nTgGgnarnIl4jMfw==} engines: {node: '>=16.11.0'} - '@discordjs/rest@2.4.2': - resolution: {integrity: sha512-9bOvXYLQd5IBg/kKGuEFq3cstVxAMJ6wMxO2U3wjrgO+lHv8oNCT+BBRpuzVQh7BoXKvk/gpajceGvQUiRoJ8g==} + '@discordjs/rest@2.4.3': + resolution: {integrity: sha512-+SO4RKvWsM+y8uFHgYQrcTl/3+cY02uQOH7/7bKbVZsTfrfpoE62o5p+mmV+s7FVhTX82/kQUGGbu4YlV60RtA==} engines: {node: '>=18'} '@discordjs/util@1.1.1': resolution: {integrity: sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==} engines: {node: '>=18'} - '@discordjs/ws@1.2.0': - resolution: {integrity: sha512-QH5CAFe3wHDiedbO+EI3OOiyipwWd+Q6BdoFZUw/Wf2fw5Cv2fgU/9UEtJRmJa9RecI+TAhdGPadMaEIur5yJg==} + '@discordjs/ws@1.2.1': + resolution: {integrity: sha512-PBvenhZG56a6tMWF/f4P6f4GxZKJTBG95n7aiGSPTnodmz4N5g60t79rSIAq7ywMbv8A4jFtexMruH+oe51aQQ==} engines: {node: '>=16.11.0'} '@es-joy/jsdoccomment@0.49.0': @@ -1021,8 +1021,8 @@ packages: discord-api-types@0.37.119: resolution: {integrity: sha512-WasbGFXEB+VQWXlo6IpW3oUv73Yuau1Ig4AZF/m13tXcTKnMpc/mHjpztIlz4+BM9FG9BHQkEXiPto3bKduQUg==} - discord.js@14.17.3: - resolution: {integrity: sha512-8/j8udc3CU7dz3Eqch64UaSHoJtUT6IXK4da5ixjbav4NAXJicloWswD/iwn1ImZEMoAV3LscsdO0zhBh6H+0Q==} + discord.js@14.18.0: + resolution: {integrity: sha512-SvU5kVUvwunQhN2/+0t55QW/1EHfB1lp0TtLZUSXVHDmyHTrdOj5LRKdR0zLcybaA15F+NtdWuWmGOX9lE+CAw==} engines: {node: '>=18'} doctrine@2.1.0: @@ -2296,8 +2296,8 @@ packages: undici-types@6.20.0: resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} - undici@6.19.8: - resolution: {integrity: sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==} + undici@6.21.1: + resolution: {integrity: sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==} engines: {node: '>=18.17'} update-browserslist-db@1.1.2: @@ -2567,7 +2567,7 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 - '@discordjs/builders@1.10.0': + '@discordjs/builders@1.10.1': dependencies: '@discordjs/formatters': 0.6.0 '@discordjs/util': 1.1.1 @@ -2585,7 +2585,7 @@ snapshots: dependencies: discord-api-types: 0.37.119 - '@discordjs/rest@2.4.2': + '@discordjs/rest@2.4.3': dependencies: '@discordjs/collection': 2.1.1 '@discordjs/util': 1.1.1 @@ -2595,14 +2595,14 @@ snapshots: discord-api-types: 0.37.119 magic-bytes.js: 1.10.0 tslib: 2.8.1 - undici: 6.19.8 + undici: 6.21.1 '@discordjs/util@1.1.1': {} - '@discordjs/ws@1.2.0': + '@discordjs/ws@1.2.1': dependencies: '@discordjs/collection': 2.1.1 - '@discordjs/rest': 2.4.2 + '@discordjs/rest': 2.4.3 '@discordjs/util': 1.1.1 '@sapphire/async-queue': 1.5.5 '@types/ws': 8.5.14 @@ -3477,20 +3477,20 @@ snapshots: discord-api-types@0.37.119: {} - discord.js@14.17.3: + discord.js@14.18.0: dependencies: - '@discordjs/builders': 1.10.0 + '@discordjs/builders': 1.10.1 '@discordjs/collection': 1.5.3 '@discordjs/formatters': 0.6.0 - '@discordjs/rest': 2.4.2 + '@discordjs/rest': 2.4.3 '@discordjs/util': 1.1.1 - '@discordjs/ws': 1.2.0 + '@discordjs/ws': 1.2.1 '@sapphire/snowflake': 3.5.3 discord-api-types: 0.37.119 fast-deep-equal: 3.1.3 lodash.snakecase: 4.1.1 tslib: 2.8.1 - undici: 6.19.8 + undici: 6.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -5018,7 +5018,7 @@ snapshots: undici-types@6.20.0: {} - undici@6.19.8: {} + undici@6.21.1: {} update-browserslist-db@1.1.2(browserslist@4.24.4): dependencies: diff --git a/src/commands/about.ts b/src/commands/about.ts new file mode 100644 index 0000000..68dc2d9 --- /dev/null +++ b/src/commands/about.ts @@ -0,0 +1,87 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { + ApplicationIntegrationType, + SlashCommandBuilder, + InteractionContextType, + Locale, +} from "discord.js"; + +const command = new SlashCommandBuilder(). + setContexts( + InteractionContextType.BotDM, + InteractionContextType.Guild, + InteractionContextType.PrivateChannel, + ). + setIntegrationTypes(ApplicationIntegrationType.UserInstall). + setName("about"). + setNameLocalizations({ + [Locale.Indonesian]: "tentang", + [Locale.EnglishGB]: "about", + [Locale.EnglishUS]: "about", + [Locale.Bulgarian]: "за", + [Locale.ChineseCN]: "关于", + [Locale.ChineseTW]: "關於", + [Locale.Czech]: "o-aplikaci", + [Locale.Danish]: "om", + [Locale.Dutch]: "over", + [Locale.Finnish]: "tietoja", + [Locale.French]: "à-propos", + [Locale.German]: "über", + [Locale.Greek]: "σχετικά-με", + [Locale.Hindi]: "के-बारे-में", + [Locale.Hungarian]: "rólunk", + [Locale.Italian]: "informazioni", + [Locale.Japanese]: "約", + [Locale.Korean]: "약", + [Locale.Lithuanian]: "apie", + [Locale.Polish]: "o-nas", + [Locale.PortugueseBR]: "sobre", + [Locale.Romanian]: "despre", + [Locale.Russian]: "о", + [Locale.SpanishES]: "acerca-de", + [Locale.SpanishLATAM]: "acerca-de", + [Locale.Swedish]: "om", + [Locale.Thai]: "เกี่ยวกับ", + [Locale.Turkish]: "hakkında", + [Locale.Ukrainian]: "про", + }). + setDescription("Learn more about this bot!"). + setDescriptionLocalizations({ + [Locale.Indonesian]: "Pelajari lebih lanjut tentang bot ini!", + [Locale.EnglishGB]: "Learn more about this bot!", + [Locale.EnglishUS]: "Learn more about this bot!", + [Locale.Bulgarian]: "Научете повече за този бот!", + [Locale.ChineseCN]: "了解有关此机器人的更多信息!", + [Locale.ChineseTW]: "了解有關此機器人的更多信息!", + [Locale.Czech]: "Dozvědět se více o tomto botovi!", + [Locale.Danish]: "Lær mere om denne bot!", + [Locale.Dutch]: "Leer meer over deze bot!", + [Locale.Finnish]: "Lisätietoja tästä botista!", + [Locale.French]: "En savoir plus sur ce bot!", + [Locale.German]: "Erfahren Sie mehr über diesen Bot!", + [Locale.Greek]: "Μάθετε περισσότερα για αυτό το bot!", + [Locale.Hindi]: "इस बॉट के बारे में और अधिक जानें!", + [Locale.Hungarian]: "Tudj meg többet erről a botról!", + [Locale.Italian]: "Scopri di più su questo bot!", + [Locale.Japanese]: "このボットについてもっと詳しく知る!", + [Locale.Korean]: "이 봇에 대해 더 알아보기!", + [Locale.Lithuanian]: "Sužinokite daugiau apie šį botą!", + [Locale.Polish]: "Dowiedz się więcej o tym bocie!", + [Locale.PortugueseBR]: "Saiba mais sobre este bot!", + [Locale.Romanian]: "Aflați mai multe despre acest bot!", + [Locale.Russian]: "Узнайте больше об этом боте!", + [Locale.SpanishES]: "¡Obtén más información sobre este bot!", + [Locale.SpanishLATAM]: "¡Obtén más información sobre este bot!", + [Locale.Swedish]: "Lär dig mer om denna bot!", + [Locale.Thai]: "เรียนรู้เพิ่มเติมเกี่ยวกับบอตนี้!", + [Locale.Turkish]: "Bu bot hakkında daha fazla bilgi edinin!", + [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())); diff --git a/src/i18n/responses.ts b/src/i18n/responses.ts index 0fdaaf9..28592d2 100644 --- a/src/i18n/responses.ts +++ b/src/i18n/responses.ts @@ -6,10 +6,21 @@ /* 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. */ +/* eslint-disable max-lines -- massive chonky boi*/ import { Locale } from "discord.js"; -export const responses: Record = { +export const responses: Record = { en: { + "button": { + code: "Source code", + support: "Need help?", + }, + "embed": { + commit: "Current Commit", + description: "Aria Iuvo is a Discord bot that uses LibreTranslate to provide translations for messages. She is developed by NHCarrigan. To use the bot, right click on a message, select `Apps`, then select `Translate message`!", + title: "About Aria Iuvo", + version: "Running Version", + }, "no-message-content": "No message content found.", "subscription-required": "You must be subscribed to translate messages.", @@ -18,201 +29,403 @@ export const responses: Record { if (interaction.isMessageContextMenuCommand()) { void translate(interaction); } + if (interaction.isChatInputCommand()) { + void about(interaction); + } }); client.on(Events.ClientReady, () => { diff --git a/src/modules/about.ts b/src/modules/about.ts new file mode 100644 index 0000000..0caa1b6 --- /dev/null +++ b/src/modules/about.ts @@ -0,0 +1,67 @@ +/** + * @copyright nhcarrigan + * @license Naomi's Public License + * @author Naomi Carrigan + */ + +import { execSync } from "node:child_process"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + MessageFlags, + type ChatInputCommandInteraction, +} from "discord.js"; +import { i18n } from "../utils/i18n.js"; +import { getLocale } from "./getLocale.js"; + +/** + * Responds with information about the bot. + * @param interaction -- The interaction payload from Discord. + */ +export const about += async(interaction: ChatInputCommandInteraction): Promise => { + await interaction.deferReply({ flags: [ MessageFlags.Ephemeral ] }); + const targetLocale = getLocale(interaction); + + const version = process.env.npm_package_version ?? "Unknown"; + const commit = execSync("git rev-parse --short HEAD").toString(). + trim(); + + const embed = new EmbedBuilder(); + embed.setTitle(i18n("embed.title", targetLocale)); + embed.setDescription(i18n("embed.description", targetLocale)); + embed.addFields( + { + name: i18n("embed.version", targetLocale), + value: version, + }, + { + name: i18n("embed.commit", targetLocale), + value: commit, + }, + ); + + const supportButton = new ButtonBuilder(). + setLabel(i18n("button.support", targetLocale)). + setStyle(ButtonStyle.Link). + setURL("https://chat.nhcarrigan.com"); + const sourceButton = new ButtonBuilder(). + setLabel(i18n("button.code", targetLocale)). + setStyle(ButtonStyle.Link). + setURL("https://git.nhcarrigan.com/nhcarrigan/aria-iuvo"); + const subscribeButton = new ButtonBuilder(). + setStyle(ButtonStyle.Premium). + setSKUId("1338596712121499669"); + const row = new ActionRowBuilder().addComponents( + supportButton, + sourceButton, + subscribeButton, + ); + + await interaction.editReply({ + components: [ row ], + embeds: [ embed ], + }); +}; diff --git a/src/modules/getLocale.ts b/src/modules/getLocale.ts index 5705ba0..16bdfca 100644 --- a/src/modules/getLocale.ts +++ b/src/modules/getLocale.ts @@ -5,7 +5,7 @@ */ import { mappedLocales } from "../config/locales.js"; -import type { MessageContextMenuCommandInteraction } from "discord.js"; +import type { CommandInteraction } from "discord.js"; /** * Parses the locale from the interaction, using our mapped @@ -14,7 +14,7 @@ import type { MessageContextMenuCommandInteraction } from "discord.js"; * @returns The locale string. */ export const getLocale = ( - interaction: MessageContextMenuCommandInteraction, + interaction: CommandInteraction, ): string => { if (mappedLocales[interaction.locale] !== undefined) { // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- It's not undefined. diff --git a/src/modules/translate.ts b/src/modules/translate.ts index 2bb575d..b2a5870 100644 --- a/src/modules/translate.ts +++ b/src/modules/translate.ts @@ -5,6 +5,9 @@ */ import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, MessageFlags, type MessageContextMenuCommandInteraction, } from "discord.js"; @@ -28,8 +31,15 @@ export const translate = async( }); if (!isEntitled && interaction.user.id !== "465650873650118659") { + const subscribeButton = new ButtonBuilder(). + setStyle(ButtonStyle.Premium). + setSKUId("1338596712121499669"); + const row = new ActionRowBuilder().addComponents( + subscribeButton, + ); await interaction.editReply({ - content: i18n("subscription-required", targetLocale), + components: [ row ], + content: i18n("subscription-required", targetLocale), }); return; } diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index ddf9043..933a716 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -3,25 +3,58 @@ * @license Naomi's Public License * @author Naomi Carrigan */ +/* eslint-disable @typescript-eslint/no-non-null-assertion -- We've already asserted the language exists through our typeguard.*/ +/* eslint-disable unicorn/no-array-reduce -- It's a clean approach here and makes sense. */ +/* eslint-disable @typescript-eslint/consistent-type-assertions -- We have likely over-engineered the hell out of this...*/ import { responses } from "../i18n/responses.js"; +const isTranslatedLocale = ( + locale: string, +): locale is keyof typeof responses => { + return locale in responses; +}; + /** * 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 rawLocale -- 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, + key: + | keyof (typeof responses)["en"] + | `embed.${keyof (typeof responses)["en"]["embed"]}` + | `button.${keyof (typeof responses)["en"]["button"]}`, + rawLocale: string, interpolation: Record = {}, ): 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. + const locale: keyof typeof responses = isTranslatedLocale(rawLocale) + ? rawLocale + : "en"; + const isNestedProperty = key.startsWith("embed.") + || key.startsWith("button."); + if (isNestedProperty) { + const [ category, property ] = key.split(".") as + | ["embed", keyof (typeof responses)["en"]["embed"]] + | ["button", keyof (typeof responses)["en"]["button"]]; + if (category === "embed") { + const string = responses[locale]![category][property]; + return Object.entries(interpolation).reduce((accumulator, [ k, v ]) => { + return accumulator.replace(`{{${k}}}`, String(v)); + }, string); + } + const string = responses[locale]![category][property]; + return Object.entries(interpolation).reduce((accumulator, [ k, v ]) => { + return accumulator.replace(`{{${k}}}`, String(v)); + }, string); + } + const string + = responses[locale]![ + key as Exclude + ]; return Object.entries(interpolation).reduce((accumulator, [ k, v ]) => { return accumulator.replace(`{{${k}}}`, String(v)); }, string); diff --git a/test/locales.spec.ts b/test/locales.spec.ts index 804b40e..d303af6 100644 --- a/test/locales.spec.ts +++ b/test/locales.spec.ts @@ -7,16 +7,74 @@ import { describe, it, expect } from "vitest"; import { supportedLocales, mappedLocales } from "../src/config/locales.js"; -const localesSupportedByLibretranslate = [ "ar", "az", "bg", "bn", "ca", "cs", "da", "de", "el", "en", "eo", "es", "et", "eu", "fa", "fi", "fr", "ga", "gl", "he", "hi", "hu", "id", "it", "ja", "ko", "lt", "lv", "ms", "nb", "nl", "pl", "pt", "ro", "ru", "sk", "sl", "sq", "sv", "th", "tl", "tr", "uk", "ur", "zh", "zt" ]; +const localesSupportedByLibretranslate = [ + "ar", + "az", + "bg", + "bn", + "ca", + "cs", + "da", + "de", + "el", + "en", + "eo", + "es", + "et", + "eu", + "fa", + "fi", + "fr", + "ga", + "gl", + "he", + "hi", + "hu", + "id", + "it", + "ja", + "ko", + "lt", + "lv", + "ms", + "nb", + "nl", + "pl", + "pt", + "ro", + "ru", + "sk", + "sl", + "sq", + "sv", + "th", + "tl", + "tr", + "uk", + "ur", + "zh", + "zt", +]; describe("i18n locales", () => { - it.each(supportedLocales)("%s should be supported by libretranslate", (lang) => { - expect.assertions(1); - expect(localesSupportedByLibretranslate, `${lang} is not supported by libretranslate`).toContain(lang); - }); + it.each(supportedLocales)( + "%s should be supported by libretranslate", + (lang) => { + expect.assertions(1); + expect( + localesSupportedByLibretranslate, + `${lang} is not supported by libretranslate`, + ).toContain(lang); + }, + ); - it.each(Object.values(mappedLocales))("%s should be mapped to a supported locale", (lang) => { - expect.assertions(1); - expect(supportedLocales, `${lang} is not supported by our app`).toContain(lang); - }); + it.each(Object.values(mappedLocales))( + "%s should be mapped to a supported locale", + (lang) => { + expect.assertions(1); + expect(supportedLocales, `${lang} is not supported by our app`).toContain( + lang, + ); + }, + ); });