diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..22df07e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +DISCORD_TOKEN="" +APPLICATION_ID="" +DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..f6842f3 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,8 @@ +{ + "root": true, + "extends": ["neon/common", "neon/node", "neon/typescript", "neon/prettier"], + "parserOptions": { + "project": ["./tsconfig.eslint.json"] + }, + "ignorePatterns": ["dist/*"] +} diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml new file mode 100644 index 0000000..75ce195 --- /dev/null +++ b/.github/workflows/crowdin.yml @@ -0,0 +1,31 @@ +name: Crowdin Action + +on: + # Uncomment to run on every push to master, this is recommended but turned off by default, in case you haven't set up your Crowdin project yet + # push: + # branches: [master] + workflow_dispatch: + +jobs: + synchronize-with-crowdin: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: crowdin action + uses: crowdin/github-action@v1 + with: + upload_translations: true + download_translations: true + auto_approve_imported: true + create_pull_request: true + skip_untranslated_strings: true + crowdin_branch_name: 'BRANCH_IN_CROWDIN' + commit_message: 'i18n: update crowdin translations' + env: + # Add these secrets to your repository's secrets + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33f55e4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +.env +bot.log diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..eba3f40 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "printWidth": 120, + "useTabs": true, + "singleQuote": true, + "quoteProps": "as-needed", + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..49d335f --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "i18n-ally.localesPaths": [ + "src/locales" + ], + "i18n-ally.keystyle": "nested" +} \ No newline at end of file diff --git a/.vscode/tako.code-snippets b/.vscode/tako.code-snippets new file mode 100644 index 0000000..0764222 --- /dev/null +++ b/.vscode/tako.code-snippets @@ -0,0 +1,19 @@ +{ + "Slash Command Template": { + "scope": "javascript,typescript", + "body": [ + "import { SlashCommandBuilder } from 'discord.js';", + "import type { Command } from '../index.ts';", + "", + "export default {", + "data: new SlashCommandBuilder()", + ".setName('NAME')", + ".setDescription('DESC')", + ".toJSON(),", + "async execute(interaction) {},", + "} satisfies Command;" + + ], + "description": "Template for a slash command. Be sure to save after inserting, to format the code." + } +} \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..e8b8069 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# MIT License + +Copyright (c) 2023 Jaron Ain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d995d0 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# TypeScript Discord Bot Template + +This is a template for creating a Discord bot using TypeScript and the Bun framework. Please note that the documentation isn't really written yet and you should come with some technical knowledge about the tech used here or at least know how to read their documentations. + +## Prerequisites +- [Bun](https://bun.sh) +- A database compatible with [Prisma](https://prisma.io). The schema file is currently set for [PostgreSQL](https://www.postgresql.org/). + +## Getting Started + +1. Clone this repository. +2. Install dependencies with `bun install`. +3. Create a `.env` file with your Discord bot token and any other necessary configuration variables as outlined in `.env.example` +4. If you want automatic uploads and downloads to Crowdin, check out `.github/workflows/crowdin.yml`! + +## Database +You can just import `prisma` from `src/database.ts` to get access to the fully typesafe database. + +## Translation +Import `i18next` from `src/i18n.ts` to get an i18next object that is already fully equipped with all the translations in `src/locales`. It's recommended to use the *i18n Ally* extension by Lokalise for a nice integration into VS Code, in order to check if your translations exist. + +## Scripts +You can run these scripts by using `bun run ` as a prefix. + +`commit`: Commit your changes using commitizen + +`format`: Lint and attempt to autofix all your files. It's recommended to use a Prettier ESLint Plugin for your Editor or to create a GitHub Workflow or similiar to lint everything. + +`deploy`: Deploy/Sync the application commands (Slash-Commands) if the regular sync command does not work. + +`start`: Start the bot. + +`dev`: Start the bot with hot-reloading on file changes. + +## Features + +- Bun instead of Node for way faster code execution +- TypeScript support, to prevent errors before going into production +- Reliable translation with i18next and Crowdin Support +- Consistency using custom helpers and a robust configuration system. + +## To-Do +- [ ] Better logging +- [ ] Documentation for a foolproof setup + +## Contributing + +Contributions are welcome! Please open an issue or pull request for any requests, changes or additions. + +## License + +This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details. diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..837b059 Binary files /dev/null and b/bun.lockb differ diff --git a/config.ts b/config.ts new file mode 100644 index 0000000..9c6275e --- /dev/null +++ b/config.ts @@ -0,0 +1,34 @@ +const config = { + // Activates dev mode, which will only sync commands to the dev guild and will log debug logs + dev: true, + // Main guild is currently unused, while the dev guild is used for syncing commands just with the guild + guilds: { + main: '884046271176912917', + dev: '884046271176912917', + }, + // The devs of the bot, used for dev only commands. Input the IDs of the devs here in a string. + devs: [''], + colors: { + primary: 0x299ba3, + accent: 0x5bd79d, + green: 0x40b056, + yellow: 0xf0e34c, + red: 0xea4d4d, + }, + emojis: { + ping: '🏓', + success: '✅', + error: '❌', + pagination: { + first: '⏮️', + previous: '◀️', + next: '▶️', + last: '⏭️', + }, + } as const, + // It's recommended to store API URLs in here, so you can easily change them later + apis: { + }, +}; + +export default config; diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 0000000..3b53842 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,127 @@ +# +# Your Crowdin credentials +# +"project_id_env": CROWDIN_PROJECT_ID +"api_token_env": CROWDIN_PERSONAL_TOKEN +"base_path": "." +"base_url": "https://api.crowdin.com" + +# +# Choose file structure in Crowdin +# e.g. true or false +# +"preserve_hierarchy": true + +# +# Files configuration +# +files: [ + { + # + # Source files filter + # e.g. "/resources/en/*.json" + # + "source": "/src/locales/en/*.json", + + # + # Where translations will be placed + # e.g. "/resources/%two_letters_code%/%original_file_name%" + # + "translation": "/src/locales/%two_letters_code%/%original_file_name%", + + # + # Files or directories for ignore + # e.g. ["/**/?.txt", "/**/[0-9].txt", "/**/*\?*.txt"] + # + # "ignore": [], + + # + # The dest allows you to specify a file name in Crowdin + # e.g. "/messages.json" + # + # "dest": "", + + # + # File type + # e.g. "json" + # + "type": "i18next_json", + + # + # The parameter "update_option" is optional. If it is not set, after the files update the translations for changed strings will be removed. Use to fix typos and for minor changes in the source strings + # e.g. "update_as_unapproved" or "update_without_changes" + # + # "update_option": "", + + # + # Start block (for XML only) + # + + # + # Defines whether to translate tags attributes. + # e.g. 0 or 1 (Default is 1) + # + # "translate_attributes": 1, + + # + # Defines whether to translate texts placed inside the tags. + # e.g. 0 or 1 (Default is 1) + # + # "translate_content": 1, + + # + # This is an array of strings, where each item is the XPaths to DOM element that should be imported + # e.g. ["/content/text", "/content/text[@value]"] + # + # "translatable_elements": [], + + # + # Defines whether to split long texts into smaller text segments + # e.g. 0 or 1 (Default is 1) + # + # "content_segmentation": 1, + + # + # End block (for XML only) + # + + # + # Start .properties block + # + + # + # Defines whether single quote should be escaped by another single quote or backslash in exported translations + # e.g. 0 or 1 or 2 or 3 (Default is 3) + # 0 - do not escape single quote; + # 1 - escape single quote by another single quote; + # 2 - escape single quote by backslash; + # 3 - escape single quote by another single quote only in strings containing variables ( {0} ). + # + # "escape_quotes": 3, + + # + # Defines whether any special characters (=, :, ! and #) should be escaped by backslash in exported translations. + # e.g. 0 or 1 (Default is 0) + # 0 - do not escape special characters + # 1 - escape special characters by a backslash + # + # "escape_special_characters": 0 + # + + # + # End .properties block + # + + # + # Does the first line contain header? + # e.g. true or false + # + # "first_line_contains_header": true, + + # + # for spreadsheets + # e.g. "identifier,source_phrase,context,uk,ru,fr" + # + # "scheme": "", + } +] \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..b6ae897 --- /dev/null +++ b/package.json @@ -0,0 +1,44 @@ +{ + "name": "tako-bun", + "version": "0.0.1-alpha.3", + "private": true, + "type": "module", + "scripts": { + "commit": "cz", + "lint": "prettier --check . && eslint ./src --ext .ts --format=pretty", + "deploy": "bun src/util/deploy.ts", + "format": "prettier --write . && eslint ./src --ext .ts --fix --format=pretty", + "start": "bun src/index.ts", + "dev": "bunx nodemon --exec bun src/index.ts" + }, + "dependencies": { + "@discordjs/core": "^1.0.1", + "@prisma/client": "5.4.2", + "discord.js": "^14.13.0", + "i18next": "^23.5.1", + "i18next-fs-backend": "^2.2.0", + "is-language-code": "^5.0.9", + "prisma": "^5.4.2", + "tslog": "^4.9.2", + "uwuifier": "^4.0.5" + }, + "devDependencies": { + "@sapphire/ts-config": "^5.0.0", + "@types/node": "^20.8.6", + "bun-types": "^1.0.6", + "commitizen": "^4.3.0", + "cz-conventional-changelog": "^3.3.0", + "eslint": "^8.51.0", + "eslint-config-neon": "^0.1.57", + "eslint-formatter-pretty": "^5.0.0", + "typescript": "^5.2.2" + }, + "nodemon": { + "ext": "js,mjs,cjs,json,ts" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..a70fd10 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,16 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Guild { + id String @id @unique + language String @default("en") +} diff --git a/src/@types/general.d.ts b/src/@types/general.d.ts new file mode 100644 index 0000000..a5e08ad --- /dev/null +++ b/src/@types/general.d.ts @@ -0,0 +1,19 @@ +import type { APIEmbedField, EmbedAuthorOptions, EmbedFooterOptions } from 'discord.js'; +import config from '../../config.ts'; + +const emojis = config.emojis; +type baseEmojis = Exclude; +type paginationEmojis = keyof (typeof config.emojis)['pagination']; + +export type EmbedOptions = { + author?: EmbedAuthorOptions; + color: number | keyof typeof config.colors; + description?: string; + emoji?: (typeof config.emojis.pagination)[paginationEmojis] | (typeof config.emojis)[baseEmojis]; + fields?: APIEmbedField[]; + footer?: EmbedFooterOptions | null | undefined; + image?: string | null; + thumbnail?: string; + timestamp?: Date | number | null | undefined; + title?: string; +}; diff --git a/src/@types/i18next.d.ts b/src/@types/i18next.d.ts new file mode 100644 index 0000000..e761fe0 --- /dev/null +++ b/src/@types/i18next.d.ts @@ -0,0 +1,9 @@ +import type { resources, defaultNS, fallbackNS } from '../i18n'; + +declare module 'i18next' { + type CustomTypeOptions = { + defaultNS: typeof defaultNS; + fallbackNS: typeof fallbackNS; + resources: typeof resources; + }; +} diff --git a/src/@types/utility.ts b/src/@types/utility.ts new file mode 100644 index 0000000..715d61d --- /dev/null +++ b/src/@types/utility.ts @@ -0,0 +1,61 @@ +export const languages = { + af: 'Afrikaans', + ar: 'Arabic (عربي)', + az: 'Azerbaijani (Azərbaycan)', + be: 'Belarusian (беларускі)', + bg: 'Bulgarian (български)', + bn: 'Bengali (বাংলা)', + ca: 'Catalan (Català)', + cs: 'Czech (Čeština)', + cy: 'Welsh (Cymraeg)', + da: 'Danish (Dansk)', + de: 'German (Deutsch)', + el: 'Greek (Ελληνικά)', + en: 'English', + es: 'Spanish (Español)', + et: 'Estonian (Eesti keel)', + eu: 'Basque (Euskara)', + fa: 'Persian (فارسی)', + fi: 'Finnish (Suomalainen)', + fr: 'French (Français)', + ga: 'Irish (Gaeilge)', + gl: 'Galician (Galego)', + gu: 'Gujarati (ગુજરાતી)', + hi: 'Hindi (हिंदी)', + hr: 'Croatian (Hrvatski)', + ht: 'Haitian Creole (Kreyòl ayisyen)', + hu: 'Hungarian (Magyar)', + id: 'Indonesian (bahasa Indonesia)', + is: 'Icelandic (Íslenskur)', + it: 'Italian (Italiano)', + iw: 'Hebrew (עִברִית)', + ja: 'Japanese (日本語)', + ka: 'Georgian (ქართული)', + kn: 'Kannada (ಕನ್ನಡ)', + ko: 'Korean (한국인)', + la: 'Latin (Latinus)', + lt: 'Lithuanian (Lietuvių)', + lv: 'Latvian (Latviski)', + mk: 'Macedonian (Македонски)', + ms: 'Malay (Melayu)', + nl: 'Dutch (Nederlands)', + no: 'Norwegian (Norsk)', + pl: 'Polish (Polski)', + pt: 'Portuguese (Português)', + ru: 'Russian (Русский)', + sk: 'Slovak (Slovenský)', + sl: 'Slovenian (Slovenščina)', + sq: 'Albanian (Shqiptare)', + sv: 'Swedish (Svenska)', + sw: 'Swahili (Kiswahili)', + ta: 'Tamil (தமிழ்)', + te: 'Telugu (తెలుగు)', + th: 'Thai (แบบไทย)', + tl: 'Talagog/Filipino', + tr: 'Turkish (Türkçe)', + uk: 'Ukrainian (Українська)', + ur: 'Urdu (اردو)', + vi: 'Vietnamese (Tiếng Việt)', + yi: 'Yiddish (יידיש)', + 'zh-CN': 'Chinese Simplified (简体中文)', +}; diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..ae544e8 --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,39 @@ +import type { RESTPostAPIApplicationCommandsJSONBody, CommandInteraction, AutocompleteInteraction, ModalSubmitInteraction, ContextMenuCommandInteraction, MessageContextMenuCommandInteraction } from 'discord.js'; +import type { StructurePredicate } from '../util/loaders.ts'; + +/** + * Defines the structure of a command + */ +export type Command = { + /** + * The function to execute when autocomplete is called + * + * @param interaction - The interaction of the command + */ + autocomplete?(interaction: AutocompleteInteraction): Promise | void; + /** + * The data for the command + */ + data: RESTPostAPIApplicationCommandsJSONBody; + /** + * The function to execute when the command is called + * + * @param interaction - The interaction of the command + */ + execute(interaction: CommandInteraction): Promise | void; + /** + * The function to execute when a modal is submitted + * + * @param interaction - The interaction of the command + */ + modalSubmit?(interaction: ModalSubmitInteraction): Promise | void; +}; + +// Defines the predicate to check if an object is a valid Command type +export const predicate: StructurePredicate = (structure): structure is Command => + Boolean(structure) && + typeof structure === 'object' && + 'data' in structure! && + 'execute' in structure && + typeof structure.data === 'object' && + typeof structure.execute === 'function'; diff --git a/src/commands/info/ping.ts b/src/commands/info/ping.ts new file mode 100644 index 0000000..570ebca --- /dev/null +++ b/src/commands/info/ping.ts @@ -0,0 +1,26 @@ +import { SlashCommandBuilder } from 'discord.js'; +import config from '../../../config.ts'; +import i18next from '../../i18n.ts'; +import { createEmbed, getLanguage, slashCommandTranslator } from '../../util/general.ts'; +import type { Command } from '../index.ts'; + +export default { + data: new SlashCommandBuilder() + .setName(i18next.t('ping.name', { ns: 'info' })) + .setNameLocalizations(slashCommandTranslator('ping.name', 'info')) + .setDescription(i18next.t('ping.description', { ns: 'info' })) + .setDescriptionLocalizations(slashCommandTranslator('ping.description', 'info')) + .toJSON(), + async execute(interaction) { + const ping = interaction.client.ws.ping; + const language = await getLanguage(interaction.guildId); + + const embed = createEmbed({ + color: ping < 200 ? 'green' : ping < 400 ? 'yellow' : 'red', + title: i18next.t('ping.title', { ns: 'info', lng: language }), + emoji: config.emojis.ping, + description: i18next.t('ping.response', { ns: 'info', lng: language, latency: ping }), + }); + await interaction.reply({ embeds: [embed] }); + }, +} satisfies Command; diff --git a/src/commands/secret/sync.ts b/src/commands/secret/sync.ts new file mode 100644 index 0000000..d094aa4 --- /dev/null +++ b/src/commands/secret/sync.ts @@ -0,0 +1,71 @@ +import { readdir } from 'node:fs/promises'; +import { API } from '@discordjs/core/http-only'; +import { REST, SlashCommandBuilder } from 'discord.js'; +import config from '../../../config.ts'; +import i18next from '../../i18n.ts'; +import { isDev } from '../../util/checks.ts'; +import { createEmbed, getLanguage, slashCommandTranslator } from '../../util/general.ts'; +import { loadCommands } from '../../util/loaders.ts'; +import type { Command } from '../index.ts'; + +export default { + data: new SlashCommandBuilder() + .setName(i18next.t('sync.name', { ns: 'secret' })) + .setNameLocalizations(slashCommandTranslator('sync.name', 'secret')) + .setDescription(i18next.t('sync.description', { ns: 'secret' })) + .setDescriptionLocalizations(slashCommandTranslator('sync.description', 'secret')) + .setDefaultMemberPermissions(0) + .toJSON(), + async execute(interaction) { + await interaction.deferReply({ ephemeral: true }); + const language = await getLanguage(interaction.guildId, interaction.user.id); + + // Check if the user is a developer based on the config + if (!isDev(interaction.user.id)) { + const embed = createEmbed({ + color: 'red', + description: i18next.t('checks.devOnly.title', { ns: 'errors', lng: language }), + emoji: config.emojis.error, + title: i18next.t('checks.devOnly.title', { ns: 'errors', lng: language }), + }); + await interaction.editReply({ embeds: [embed] }); + return; + } + + const commands = []; + const commandData = []; + + const directory = `${import.meta.dir.slice(0, Math.max(0, import.meta.dir.length - 7))}`; + for (const file of (await readdir(directory, { withFileTypes: true })) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name)) { + commands.push(await loadCommands(Bun.pathToFileURL(`${directory}/${file}`))); + } + + for (const command of commands) { + for (const cmd of [...command.values()].map((command) => command.data)) { + commandData.push(cmd); + } + } + + const rest = new REST({ version: '10' }).setToken(Bun.env.DISCORD_TOKEN!); + const api = new API(rest); + + let result; + if (config.dev && config.guilds.dev) { + result = await api.applicationCommands.bulkOverwriteGuildCommands( + Bun.env.APPLICATION_ID!, + config.guilds.dev, + commandData, + ); + } else { + result = await api.applicationCommands.bulkOverwriteGlobalCommands(Bun.env.APPLICATION_ID!, commandData); + } + + await interaction.editReply({ + content: `Successfully registered ${result.length} commands.${ + config.dev ? ` On the guild ${config.guilds.dev}` : '' + }`, + }); + }, +} satisfies Command; diff --git a/src/commands/utility/changeLanguage.ts b/src/commands/utility/changeLanguage.ts new file mode 100644 index 0000000..5495276 --- /dev/null +++ b/src/commands/utility/changeLanguage.ts @@ -0,0 +1,140 @@ +import type { ChatInputCommandInteraction } from 'discord.js'; +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; +import { isLangCode } from 'is-language-code'; +import config from '../../../config.ts'; +import { languages } from '../../@types/utility.ts'; +import prisma from '../../database.ts'; +import i18next from '../../i18n.ts'; +import { createEmbed, getLanguage, slashCommandTranslator } from '../../util/general.ts'; +import type { Command } from '../index.ts'; + +async function logic( + interaction: ChatInputCommandInteraction, + language: string, + responseLanguage: string, + personal: boolean, +) { + if (!personal && !interaction.guildId) { + const invalidLanguage = createEmbed({ + color: 'red', + title: i18next.t('serverRequired.title', { ns: 'errors', lng: responseLanguage }), + description: i18next.t('serverRequired.description', { ns: 'errors', lng: responseLanguage }), + emoji: config.emojis.error, + }); + await interaction.reply({ embeds: [invalidLanguage], ephemeral: true }); + return; + } + + if (!isLangCode(language).res) { + const invalidLanguage = createEmbed({ + color: 'red', + title: i18next.t('language.invalidLanguage.title', { ns: 'errors', lng: responseLanguage }), + description: i18next.t('language.invalidLanguage.description', { ns: 'errors', lng: responseLanguage }), + emoji: config.emojis.error, + }); + await interaction.reply({ embeds: [invalidLanguage], ephemeral: true }); + return; + } + + if (!personal && interaction.guildId) { + await prisma.guild.upsert({ + where: { id: interaction.guildId }, + create: { id: interaction.guildId, language }, + update: { language }, + }); + } else if (personal) { + await prisma.user.upsert({ + where: { id: interaction.user.id }, + create: { id: interaction.user.id, language }, + update: { language }, + }); + } + + const success = createEmbed({ + color: 'green', + title: i18next.t('changeLanguage.response', { ns: 'utility', language }), + emoji: config.emojis.success, + }); + + await interaction.reply({ embeds: [success], ephemeral: true }); +} + +export default { + data: new SlashCommandBuilder() + .setName(i18next.t('changeLanguage.name', { ns: 'utility' })) + .setNameLocalizations(slashCommandTranslator('changeLanguage.name', 'utility')) + .setDescription(i18next.t('changeLanguage.description', { ns: 'utility' })) + .setDescriptionLocalizations(slashCommandTranslator('changeLanguage.description', 'utility')) + .addSubcommand((subcommand) => + subcommand + .setName(i18next.t('changeLanguage.personal.name', { ns: 'utility' })) + .setNameLocalizations(slashCommandTranslator('changeLanguage.personal.name', 'utility')) + .setDescription(i18next.t('changeLanguage.personal.description', { ns: 'utility' })) + .setDescriptionLocalizations(slashCommandTranslator('changeLanguage.personal.description', 'utility')) + .addStringOption((option) => + option + .setName(i18next.t('changeLanguage.options.language.name', { ns: 'utility' })) + .setNameLocalizations(slashCommandTranslator('changeLanguage.options.language.name', 'utility')) + .setDescription(i18next.t('changeLanguage.options.language.description', { ns: 'utility' })) + .setDescriptionLocalizations( + slashCommandTranslator('changeLanguage.options.language.description', 'utility'), + ) + .setAutocomplete(true) + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName(i18next.t('changeLanguage.server.name', { ns: 'utility' })) + .setNameLocalizations(slashCommandTranslator('changeLanguage.server.name', 'utility')) + .setDescription(i18next.t('changeLanguage.server.description', { ns: 'utility' })) + .setDescriptionLocalizations(slashCommandTranslator('changeLanguage.server.description', 'utility')) + .addStringOption((option) => + option + .setName(i18next.t('changeLanguage.options.language.name', { ns: 'utility' })) + .setNameLocalizations(slashCommandTranslator('changeLanguage.options.language.name', 'utility')) + .setDescription(i18next.t('changeLanguage.options.language.description', { ns: 'utility' })) + .setDescriptionLocalizations( + slashCommandTranslator('changeLanguage.options.language.description', 'utility'), + ) + .setAutocomplete(true) + .setRequired(true), + ), + ) + .setDefaultMemberPermissions(PermissionsBitField.Flags.ManageGuild) + .toJSON(), + async execute(interaction: ChatInputCommandInteraction) { + const subcommand = interaction.options.getSubcommand(); + const language = + interaction.options.getString(i18next.t('changeLanguage.options.language.name', { ns: 'utility' })) ?? 'en'; + const responseLanguage = await getLanguage( + interaction.guildId, + subcommand === i18next.t('changeLanguage.personal.name', { ns: 'utility' }) ? interaction.user.id : undefined, + ); + + if (subcommand === i18next.t('changeLanguage.server.name', { ns: 'utility' })) { + await logic(interaction, language, responseLanguage, false); + } else { + await logic(interaction, language, responseLanguage, true); + } + }, + async autocomplete(interaction) { + const focusedValue = interaction.options.getFocused(); + const filtered = []; + for (const key in languages) { + if (Object.hasOwn(languages, key)) { + // @ts-expect-error This value cannot be undefined, because the key is directly from the object. + const value: string = languages[key]; + if ( + value.toLowerCase().includes(focusedValue.toLowerCase()) || + key.toLowerCase().includes(focusedValue.toLowerCase()) + ) { + filtered.push({ name: value, value: key }); + } + } + } + + const limited = filtered.slice(0, 25); + await interaction.respond(limited); + }, +} satisfies Command; diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..4e54f7a --- /dev/null +++ b/src/database.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export default prisma; diff --git a/src/events/index.ts b/src/events/index.ts new file mode 100644 index 0000000..f6d7287 --- /dev/null +++ b/src/events/index.ts @@ -0,0 +1,33 @@ +import type { ClientEvents } from 'discord.js'; +import type { StructurePredicate } from '../util/loaders.ts'; + +/** + * Defines the structure of an event. + */ +export type Event = { + /** + * The function to execute when the event is emitted. + * + * @param parameters - The parameters of the event + */ + execute(...parameters: ClientEvents[T]): Promise | void; + /** + * The name of the event to listen to + */ + name: T; + /** + * Whether or not the event should only be listened to once + * + * @defaultValue false + */ + once?: boolean; +}; + +// Defines the predicate to check if an object is a valid Event type. +export const predicate: StructurePredicate = (structure): structure is Event => + Boolean(structure) && + typeof structure === 'object' && + 'name' in structure! && + 'execute' in structure && + typeof structure.name === 'string' && + typeof structure.execute === 'function'; diff --git a/src/events/ready.ts b/src/events/ready.ts new file mode 100644 index 0000000..b682dbc --- /dev/null +++ b/src/events/ready.ts @@ -0,0 +1,31 @@ +import { setInterval } from 'node:timers'; +import { ActivityType, Events } from 'discord.js'; +import { logger } from '../util/logger.ts'; +import type { Event } from './index.ts'; + +export default { + name: Events.ClientReady, + once: true, + async execute(client) { + logger.info(`Ready! Logged in as ${client.user.tag}`); + + setInterval(() => { + const activities = [ + { activity: `with version ${Bun.env.npm_package_version}`, type: ActivityType.Playing }, + { activity: `/ Commands`, type: ActivityType.Listening }, + { + activity: `${client.users.cache.size} user${client.users.cache.size > 1 ? 's' : ''}`, + type: ActivityType.Listening, + }, + { + activity: `over ${client.guilds.cache.size} server${client.guilds.cache.size > 1 ? 's' : ''}`, + type: ActivityType.Watching, + }, + ]; + + const randomIndex = Math.floor(Math.random() * activities.length); + + client.user.setActivity(activities[randomIndex].activity, { type: activities[randomIndex].type }); + }, 7_500); + }, +} satisfies Event<'ready'>; diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..dde211e --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,25 @@ +import { readdirSync } from 'node:fs'; +import i18next from 'i18next'; +import type { FsBackendOptions } from 'i18next-fs-backend'; +import FsBackend from 'i18next-fs-backend'; + +await i18next + .use(FsBackend) + .init({ + fallbackLng: 'en', + returnEmptyString: false, + backend: { + loadPath: (import.meta.dir + '/locales/{{lng}}/{{ns}}.json') + } + }); + +const locales = readdirSync(import.meta.dir + '/locales') +const namespaces = readdirSync(import.meta.dir + '/locales/en') +for (const ns of namespaces) { + namespaces[namespaces.indexOf(ns)] = ns.split('.')[0] +} + +await i18next.loadNamespaces(namespaces); +await i18next.loadLanguages(locales); + +export { default } from 'i18next'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..515a9d8 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,26 @@ +import { readdir } from 'node:fs/promises'; +import { URL } from 'node:url'; +import { Client, GatewayIntentBits } from 'discord.js'; +import { loadCommands, loadEvents } from './util/loaders.ts'; +import { registerEvents } from './util/registerEvents.ts'; + +const client = new Client({ intents: [GatewayIntentBits.Guilds] }); + +const commandArray = []; +for (const dir of (await readdir(`${import.meta.dir}/commands`, { withFileTypes: true })) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name)) { + commandArray.push(await loadCommands(Bun.pathToFileURL(`${import.meta.dir}/commands/${dir}`))); +} + +const commands = new Map(); +for (const cmd of commandArray) { + for (const [key, value] of cmd) { + commands.set(key, value); + } +} + +const events = await loadEvents(new URL('events/', import.meta.url)); +registerEvents(client, commands, events); + +void client.login(Bun.env.DISCORD_TOKEN); diff --git a/src/locales/de/errors.json b/src/locales/de/errors.json new file mode 100644 index 0000000..52570d7 --- /dev/null +++ b/src/locales/de/errors.json @@ -0,0 +1,18 @@ +{ + "checks": { + "devOnly": { + "title": "Geheime Sachen", + "description": "Du musst ein Entwickler sein, um diesen Befehl zu benutzen." + } + }, + "language": { + "invalidLanguage": { + "title": "Ungültige Sprache", + "description": "Das ist keine gültige Sprache. Bitte folge dem Format `xx` (`en`, `de`, etc.), oder `xx-XX` (`en-US`, `zh-CN`, etc.), während `x` einen Teil deines Sprachcodes darstellt." + } + }, + "serverRequired": { + "title": "Server erforderlich", + "description": "Du musst diesen Befehl auf einem Server ausführen." + } +} diff --git a/src/locales/de/info.json b/src/locales/de/info.json new file mode 100644 index 0000000..ded60d9 --- /dev/null +++ b/src/locales/de/info.json @@ -0,0 +1,8 @@ +{ + "ping": { + "name": "ping", + "description": "Pinge den Bot", + "title": "Pong!", + "response": "{{latency, number}}ms." + } +} \ No newline at end of file diff --git a/src/locales/de/secret.json b/src/locales/de/secret.json new file mode 100644 index 0000000..1d9155e --- /dev/null +++ b/src/locales/de/secret.json @@ -0,0 +1,6 @@ +{ + "sync": { + "name": "synchronisiere", + "description": "Synchronisiere die Befehle mit Discord" + } +} diff --git a/src/locales/de/utility.json b/src/locales/de/utility.json new file mode 100644 index 0000000..baf8829 --- /dev/null +++ b/src/locales/de/utility.json @@ -0,0 +1,21 @@ +{ + "changeLanguage": { + "name": "sprache-setzen", + "description": "Ändere die Sprache des Bots", + "personal": { + "name": "persönlich", + "description": "Ändern Sie die Sprache des Bots für dich selbst und persönliche Nachrichten" + }, + "server": { + "name": "server", + "description": "Wechselt die Sprache des Bots für den gesamten Server" + }, + "options": { + "language": { + "name": "sprache", + "description": "Die Sprache, die du für den Bot verwenden möchtest" + } + }, + "response": "Die Sprache wurde erfolgreich zu `{{language}}` geändert" + } +} diff --git a/src/locales/en/errors.json b/src/locales/en/errors.json new file mode 100644 index 0000000..b39b0b6 --- /dev/null +++ b/src/locales/en/errors.json @@ -0,0 +1,18 @@ +{ + "checks": { + "devOnly": { + "title": "Secret Stuff", + "description": "You need to be a Developer of the bot in order to run this command." + } + }, + "language": { + "invalidLanguage": { + "title": "Invalid Language", + "description": "That is not a valid language. Please follow the format of `xx` (`en`, `de`, etc.) or `xx-XX` (`en-US`, `zh-CN`, etc.), while `x` represents a part of your language code." + } + }, + "serverRequired": { + "title": "Server Required", + "description": "You need to run this command in a server." + } +} diff --git a/src/locales/en/info.json b/src/locales/en/info.json new file mode 100644 index 0000000..b9a1f47 --- /dev/null +++ b/src/locales/en/info.json @@ -0,0 +1,9 @@ +{ + "ping": { + "name": "ping", + "description": "Ping the bot", + "title": "Pong!", + "response": "{{latency, number}}ms." + } + +} \ No newline at end of file diff --git a/src/locales/en/secret.json b/src/locales/en/secret.json new file mode 100644 index 0000000..7f39956 --- /dev/null +++ b/src/locales/en/secret.json @@ -0,0 +1,6 @@ +{ + "sync": { + "name": "sync", + "description": "Sync application commands with Discord" + } +} diff --git a/src/locales/en/utility.json b/src/locales/en/utility.json new file mode 100644 index 0000000..2694b7f --- /dev/null +++ b/src/locales/en/utility.json @@ -0,0 +1,21 @@ +{ + "changeLanguage": { + "name": "set-language", + "description": "Change the language of the bot", + "personal": { + "name": "personal", + "description": "Change the language of the bot for yourself and personal messages" + }, + "server": { + "name": "server", + "description": "Change the language of the bot for the whole server" + }, + "options": { + "language": { + "name": "language", + "description": "The language you want to use for the bot" + } + }, + "response": "Successfully changed the language to `{{language}}`" + } +} diff --git a/src/util/checks.ts b/src/util/checks.ts new file mode 100644 index 0000000..5c8dfb2 --- /dev/null +++ b/src/util/checks.ts @@ -0,0 +1,5 @@ +import config from '../../config.ts'; + +export function isDev(id: string) { + return config.devs.includes(id); +} diff --git a/src/util/deploy.ts b/src/util/deploy.ts new file mode 100644 index 0000000..9dd1c2a --- /dev/null +++ b/src/util/deploy.ts @@ -0,0 +1,43 @@ +import { readdir } from 'node:fs/promises'; +import { API } from '@discordjs/core/http-only'; +import { REST } from 'discord.js'; +import config from '../../config.ts'; +import { loadCommands } from './loaders.ts'; + +const commands = []; +const commandData = []; + +for (const dir of ( + await readdir(`${import.meta.dir.slice(0, import.meta.dir.length - 5)}/commands`, { withFileTypes: true }) +) + .filter((dirent) => dirent.isDirectory()) + .map((dirent) => dirent.name)) { + commands.push( + await loadCommands(Bun.pathToFileURL(`${import.meta.dir.slice(0, import.meta.dir.length - 5)}/commands/${dir}`)), + ); +} + +for (const command of commands) { + for (const cmd of [...command.values()].map((command) => command.data)) { + commandData.push(cmd); + } +} + +const rest = new REST({ version: '10' }).setToken(Bun.env.DISCORD_TOKEN!); +const api = new API(rest); + +let result; +if (config.dev && config.guilds.dev) { + result = await api.applicationCommands.bulkOverwriteGuildCommands( + Bun.env.APPLICATION_ID!, + config.guilds.dev, + commandData, + ); +} else { + result = await api.applicationCommands.bulkOverwriteGlobalCommands(Bun.env.APPLICATION_ID!, commandData); + await api.applicationCommands.bulkOverwriteGuildCommands(Bun.env.APPLICATION_ID!, config.guilds.dev!, []); +} + +console.info( + `Successfully registered ${result.length} commands.${config.dev ? ` In the guild ${config.guilds.dev}!` : ''}`, +); diff --git a/src/util/general.ts b/src/util/general.ts new file mode 100644 index 0000000..a129fd6 --- /dev/null +++ b/src/util/general.ts @@ -0,0 +1,83 @@ +import type { Client } from 'discord.js'; +import { EmbedBuilder, Locale } from 'discord.js'; +import config from '../../config.ts'; +import type { EmbedOptions } from '../@types/general'; +import prisma from '../database.ts'; +import i18next from '../i18n.ts'; + +export function createEmbed({ + author, + color = 'primary', + description, + emoji, + fields, + footer, + image, + thumbnail, + timestamp, + title, +}: EmbedOptions) { + const embedTitle = emoji ? `${emoji} ${title ?? ''}` : title; + const embed = new EmbedBuilder().setColor(typeof color === 'string' ? config.colors[color] : color); + + if (embedTitle) embed.setTitle(embedTitle); + if (description) embed.setDescription(description); + if (fields) embed.setFields(fields); + if (thumbnail) embed.setThumbnail(thumbnail); + if (image) embed.setImage(image); + if (timestamp) embed.setTimestamp(timestamp); + if (footer) embed.setFooter(footer); + if (author) embed.setAuthor(author); + + return embed; +} + +export async function getColor(guildId: string | null, userId?: string, client?: Client) { + let color = config.colors.primary; + if (guildId) { + const guild = await prisma.guild.findFirst({ where: { id: guildId } }); + if (guild?.color) color = guild.color; + } + + if (userId) { + const user = await prisma.user.findFirst({ where: { id: userId } }); + + if (!user?.color && client) { + const user = await client.users.fetch(userId, { force: true }); + if (user) color = user.accentColor ?? color; + return color; + } + + color = user?.color ?? color; + } + + return color; +} + +export async function getLanguage(guildId: string | null, userId?: string, prioritizeGuild = false) { + let language = 'en'; + let guildAvailable = false; + if (guildId) { + const guild = await prisma.guild.findFirst({ where: { id: guildId } }); + if (guild?.language) language = guild.language; + if (guild?.language && prioritizeGuild) guildAvailable = true; + } + + if (userId && !guildAvailable) { + const user = await prisma.user.findFirst({ where: { id: userId } }); + if (user?.language) language = user.language; + } + + return language; +} + +export function slashCommandTranslator(key: string, ns: string) { + const translation: { [index: string]: string } = {}; + const locales = Object.values(Locale); + + for (const locale of locales) { + translation[locale] = i18next.t(key, { ns, lng: locale }); + } + + return translation; +} diff --git a/src/util/loaders.ts b/src/util/loaders.ts new file mode 100644 index 0000000..0338b2c --- /dev/null +++ b/src/util/loaders.ts @@ -0,0 +1,76 @@ +import type { PathLike } from 'node:fs'; +import { readdir, stat } from 'node:fs/promises'; +import { URL } from 'node:url'; +import type { Command } from '../commands/index.ts'; +import { predicate as commandPredicate } from '../commands/index.ts'; +import type { Event } from '../events/index.ts'; +import { predicate as eventPredicate } from '../events/index.ts'; + +/** + * A predicate to check if the structure is valid + */ +export type StructurePredicate = (structure: unknown) => structure is T; + +/** + * Loads all the structures in the provided directory + * + * @param dir - The directory to load the structures from + * @param predicate - The predicate to check if the structure is valid + * @param recursive - Whether to recursively load the structures in the directory + * @returns + */ +export async function loadStructures( + dir: PathLike, + predicate: StructurePredicate, + recursive = true, +): Promise { + // Get the stats of the directory + const statDir = await stat(dir); + + // If the provided directory path is not a directory, throw an error + if (!statDir.isDirectory()) { + throw new Error(`The directory '${dir}' is not a directory.`); + } + + // Get all the files in the directory + const files = await readdir(dir); + + // Create an empty array to store the structures + const structures: T[] = []; + + // Loop through all the files in the directory + for (const file of files) { + // If the file is index.ts or the file does not end with .ts, skip the file + if (file === 'index.ts' || !file.endsWith('.ts')) { + continue; + } + + // Get the stats of the file + const statFile = await stat(new URL(`${dir}/${file}`)); + + // If the file is a directory and recursive is true, recursively load the structures in the directory + if (statFile.isDirectory() && recursive) { + structures.push(...(await loadStructures(`${dir}/${file}`, predicate, recursive))); + continue; + } + + // Import the structure dynamically from the file + const structure = (await import(`${dir}/${file}`)).default; + + // If the structure is a valid structure, add it + if (predicate(structure)) structures.push(structure); + } + + return structures; +} + +export async function loadCommands(dir: PathLike, recursive = true): Promise> { + return (await loadStructures(dir, commandPredicate, recursive)).reduce( + (acc, cur) => acc.set(cur.data.name, cur), + new Map(), + ); +} + +export async function loadEvents(dir: PathLike, recursive = true): Promise { + return loadStructures(dir, eventPredicate, recursive); +} diff --git a/src/util/logger.ts b/src/util/logger.ts new file mode 100644 index 0000000..3bd8129 --- /dev/null +++ b/src/util/logger.ts @@ -0,0 +1,24 @@ +import type { ILogObj } from 'tslog'; +import { Logger } from 'tslog'; +import config from '../../config.ts'; + +const file = Bun.file('./bot.log'); +await Bun.write(file, ''); +const writer = file.writer(); + +export const logger: Logger = new Logger({ + minLevel: config.dev ? 2 : 3, + type: 'hidden', +}); +logger.attachTransport(async (logObj) => { + writer.ref(); + // eslint-disable-next-line @typescript-eslint/no-base-to-string + writer.write( + `${logObj._meta.date.toUTCString()} | ${logObj._meta.logLevelName} | ${ + // eslint-disable-next-line @typescript-eslint/no-base-to-string + logObj['0'] + }\n`, + ); + await writer.flush(); + writer.unref(); +}); diff --git a/src/util/registerEvents.ts b/src/util/registerEvents.ts new file mode 100644 index 0000000..2e68c26 --- /dev/null +++ b/src/util/registerEvents.ts @@ -0,0 +1,52 @@ +import { Events, type Client } from 'discord.js'; +import type { Command } from '../commands/index.ts'; +import type { Event } from '../events/index.ts'; + +export function registerEvents(client: Client, commands?: Map, events?: Event[]): void { + if (commands) { + const interactionCreateEvent: Event = { + name: Events.InteractionCreate, + async execute(interaction) { + if (interaction.isCommand() || interaction.isContextMenuCommand()) { + const command = commands.get(interaction.commandName); + + if (!command) { + throw new Error(`Command '${interaction.commandName}' not found.`); + } + + await command.execute(interaction); + } + + if (interaction.isAutocomplete()) { + const command = commands.get(interaction.commandName); + + if (!command?.autocomplete) { + throw new Error(`Command '${interaction.commandName}' not found.`); + } + + await command.autocomplete(interaction); + } + + if (interaction.isModalSubmit()) { + const command = commands.get(interaction.customId); + + if (!command?.modalSubmit) { + throw new Error(`Command '${interaction.customId}' not found.`); + } + + await command.modalSubmit(interaction); + } + }, + }; + + client[interactionCreateEvent.once ? 'once' : 'on'](interactionCreateEvent.name, async (...args) => + interactionCreateEvent.execute(...args), + ); + } + + if (events) { + for (const event of events) { + client[event.once ? 'once' : 'on'](event.name, async (...args) => event.execute(...args)); + } + } +} diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..11bd8ea --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowJs": true + }, + "include": ["**/*.ts", "**/*.js", "**/*.test.ts", "**/*.test.js"], + "exclude": [] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0856b2d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // add Bun type definitions + "types": ["bun-types"], + + // enable latest features + "lib": ["esnext"], + "module": "esnext", + "target": "esnext", + + // if TS 5.x+ + "moduleResolution": "bundler", + "noEmit": true, + "allowImportingTsExtensions": true, + "moduleDetection": "force", + // if TS 4.x or earlier + // "moduleResolution": "nodenext", + + "jsx": "react-jsx", // support JSX + "allowJs": true, // allow importing `.js` from `.ts` + "esModuleInterop": true, // allow default imports for CommonJS modules + + // best practices + "strict": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + } +}