diff --git a/.commitlintrc b/.commitlintrc new file mode 100644 index 0000000..0df1d25 --- /dev/null +++ b/.commitlintrc @@ -0,0 +1,5 @@ +{ + "extends": [ + "@commitlint/config-conventional" + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..558a922 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +POSTGRES_USER=your_postgres_user +POSTGRES_PASSWORD=your_postgres_password +POSTGRES_DB=your_database_name +VALKEY_PASSWORD=your_valkey_password diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..01ce41d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: ahmadk953 +patreon: poixpixel +thanks_dev: u/gh/ahmadk953 diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..c33af6f --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,39 @@ +name: Commitlint + +on: [push, pull_request] + +jobs: + commitlint: + name: Run commitlint scanning + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [23.x] + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure Corepack + run: corepack enable + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: yarn + + - name: Install commitlint + run: | + yarn add conventional-changelog-conventionalcommits + yarn add commitlint@latest + + - name: Validate current commit (last commit) with commitlint + if: github.event_name == 'push' + run: npx commitlint --last --verbose + + - name: Validate PR commits with commitlint + if: github.event_name == 'pull_request' + run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose diff --git a/.github/workflows/npm-build-and-compile.yml b/.github/workflows/npm-build-and-compile.yml index d22aa36..51c4c1a 100644 --- a/.github/workflows/npm-build-and-compile.yml +++ b/.github/workflows/npm-build-and-compile.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: - node-version: [21.x] + node-version: [23.x] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index fa61b2c..84eeb8c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ target/ node_modules/ drizzle/ .vscode/ +certs/ config.json -.yarn \ No newline at end of file +.env +.yarn diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..34414dd --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +yarn dlx commitlint --edit \ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..3723623 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +yarn lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 0000000..c69bd51 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +yarn compile diff --git a/.lintstagedrc.mjs b/.lintstagedrc.mjs new file mode 100644 index 0000000..5d55c53 --- /dev/null +++ b/.lintstagedrc.mjs @@ -0,0 +1,12 @@ +import path from 'path'; +import process from 'process'; + +const buildEslintCommand = (filenames) => + `eslint ${filenames.map((f) => path.relative(process.cwd(), f)).join(' ')}`; + +const prettierCommand = 'prettier --write'; + +export default { + '*.{js,mjs,ts,mts}': [prettierCommand, buildEslintCommand], + '*.json': [prettierCommand], +}; diff --git a/.prettierignore b/.prettierignore index c34419c..4b7038e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,8 +4,10 @@ drizzle/ .vscode/ .github/ .yarn/ +docs/ +certs/ config.json config.example.json package.json yarn.lock -README.md \ No newline at end of file +README.md diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 64de8d6..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "version": "0.1.0", - "configurations": [ - { - "name": "Build and Run", - "type": "node", - "request": "launch", - "program": "${workspaceFolder}/target/_.cjs", - "preLaunchTask": "build", - "skipFiles": ["/**"], - "outFiles": ["${workspaceFolder}/target/**/*.cjs"] - } - ], - "tasks": [ - { - "label": "build", - "type": "shell", - "command": "node", - "args": ["${workspaceFolder}/build/compile.js"], - "group": { - "kind": "build", - "isDefault": true - } - } - ] -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index a03a72f..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "tasks": [ - { - "label": "build", - "type": "shell", - "command": "node", - "args": ["${workspaceFolder}/build/compile.js"], - "group": { - "kind": "build", - "isDefault": true - } - } - ] -} diff --git a/README.md b/README.md index 2a8a40e..443b208 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,38 @@ # Poixpixel's Discord Bot > [!WARNING] -> This Discord bot is not production ready and everything is subject to change +> This Discord bot is not production ready and is still in a testing state. + +> [!TIP] +> Want to see the bot in action? [Join our Discord server](https://discord.gg/KRTGjxx7gY). + +## Documentation & Setup Instructions + +> [!WARNING] +> Documentation is still under construction. Expect incomplete and undocumented features. + +All documentation and setup instructions can be found at [https://docs.poixpixel.ahmadk953.org/](https://docs.poixpixel.ahmadk953.org/?utm_source=github&utm_medium=readme&utm_campaign=repository&utm_content=docs_link) ## Development Commands Install Dependencies: ``yarn install`` +Lint: ``yarn lint`` + +Check Formatting: ``yarn format`` + +Fix Formatting: ``yarn format:fix`` + Compile: ``yarn compile`` Start: ``yarn target`` -Build & Start: ``yarn start`` +Undeploy All Commands: ``yarn undeploy-commands`` + +Build & Start (dev): ``yarn start:dev`` + +Build & Start without Command Deployment (dev): ``yarn start:dev:no-deploy`` + +Build & Start (prod): ``yarn start:prod`` + +Restart (works only when the bot is started with ``yarn start:prod``): ``yarn restart`` diff --git a/assets/fonts/Manrope-Bold.ttf b/assets/fonts/Manrope-Bold.ttf new file mode 100644 index 0000000..98c1c3d Binary files /dev/null and b/assets/fonts/Manrope-Bold.ttf differ diff --git a/assets/fonts/Manrope-Regular.ttf b/assets/fonts/Manrope-Regular.ttf new file mode 100644 index 0000000..1a07233 Binary files /dev/null and b/assets/fonts/Manrope-Regular.ttf differ diff --git a/assets/images/trophy.png b/assets/images/trophy.png new file mode 100644 index 0000000..78b399b Binary files /dev/null and b/assets/images/trophy.png differ diff --git a/assets/welcome-bg.png b/assets/images/welcome-bg.png similarity index 100% rename from assets/welcome-bg.png rename to assets/images/welcome-bg.png diff --git a/config.example.json b/config.example.json index 28fca15..19f9201 100644 --- a/config.example.json +++ b/config.example.json @@ -1,16 +1,63 @@ { - "token": "DISCORD_BOT_API_KEY", + "token": "DISCORD_BOT_TOKEN", "clientId": "DISCORD_BOT_ID", "guildId": "DISCORD_SERVER_ID", - "dbConnectionString": "POSTGRESQL_CONNECTION_STRING", - "redisConnectionString": "REDIS_CONNECTION_STRING", + "serverInvite": "DISCORD_SERVER_INVITE_LINK", + "database": { + "dbConnectionString": "POSTGRESQL_CONNECTION_STRING", + "maxRetryAttempts": "MAX_RETRY_ATTEMPTS", + "retryDelay": "RETRY_DELAY_IN_MS" + }, + "redis": { + "redisConnectionString": "REDIS_CONNECTION_STRING", + "retryAttempts": "RETRY_ATTEMPTS", + "initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS" + }, "channels": { "welcome": "WELCOME_CHANNEL_ID", - "logs": "LOG_CHAANNEL_ID" + "logs": "LOG_CHANNEL_ID", + "counting": "COUNTING_CHANNEL_ID", + "factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID", + "factApproval": "FACT_APPROVAL_CHANNEL_ID", + "advancements": "ADVANCEMENTS_CHANNEL_ID" }, "roles": { "joinRoles": [ "JOIN_ROLE_IDS" - ] + ], + "levelRoles": [ + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + }, + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + }, + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + } + ], + "staffRoles": [ + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + }, + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + }, + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + } + ], + "factPingRole": "FACT_OF_THE_DAY_ROLE_ID" + }, + "leveling": { + "xpCooldown": "XP_COOLDOWN_IN_SECONDS", + "minXpAwarded": "MINIMUM_XP_AWARDED", + "maxXpAwarded": "MAXIMUM_XP_AWARDED" } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b7cb6c4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + postgres: + image: postgres:17-alpine + container_name: postgres + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - ./certs/psql-server.crt:/var/lib/postgresql/server.crt:ro + - ./certs/psql-server.key:/var/lib/postgresql/server.key:ro + - postgres_data:/var/lib/postgresql/data + ports: + - '5432:5432' + command: > + postgres + -c ssl=on + -c ssl_cert_file=/var/lib/postgresql/server.crt + -c ssl_key_file=/var/lib/postgresql/server.key + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER}'] + interval: 10s + timeout: 5s + retries: 5 + networks: + - backend + + valkey: + image: valkey/valkey:8-alpine + container_name: valkey + restart: always + ports: + - '6379:6379' + volumes: + - ./certs/cache-server.crt:/certs/server.crt:ro + - ./certs/cache-server.key:/certs/server.key:ro + - ./certs/cache-ca.crt:/certs/ca.crt:ro + - valkey_data:/data + command: > + valkey-server + --requirepass ${VALKEY_PASSWORD} + --tls-port 6379 + --port 0 + --tls-cert-file /certs/server.crt + --tls-key-file /certs/server.key + --tls-ca-cert-file /certs/ca.crt + healthcheck: + test: ['CMD', 'valkey-cli', '-a', '${VALKEY_PASSWORD}', 'ping'] + interval: 10s + timeout: 5s + retries: 5 + networks: + - backend + +volumes: + postgres_data: + valkey_data: + +networks: + backend: + driver: bridge diff --git a/docs/bot/.gitbook/assets/AdvancedSettingsTab.png b/docs/bot/.gitbook/assets/AdvancedSettingsTab.png new file mode 100644 index 0000000..f3aeb4a Binary files /dev/null and b/docs/bot/.gitbook/assets/AdvancedSettingsTab.png differ diff --git a/docs/bot/.gitbook/assets/AuthorizeDiscordBot.png b/docs/bot/.gitbook/assets/AuthorizeDiscordBot.png new file mode 100644 index 0000000..6f9a0e8 Binary files /dev/null and b/docs/bot/.gitbook/assets/AuthorizeDiscordBot.png differ diff --git a/docs/bot/.gitbook/assets/BasicBotConfigOptions.png b/docs/bot/.gitbook/assets/BasicBotConfigOptions.png new file mode 100644 index 0000000..f3ee71e Binary files /dev/null and b/docs/bot/.gitbook/assets/BasicBotConfigOptions.png differ diff --git a/docs/bot/.gitbook/assets/BotAddedSuccessMessage.png b/docs/bot/.gitbook/assets/BotAddedSuccessMessage.png new file mode 100644 index 0000000..0d9393e Binary files /dev/null and b/docs/bot/.gitbook/assets/BotAddedSuccessMessage.png differ diff --git a/docs/bot/.gitbook/assets/BotHomePage.png b/docs/bot/.gitbook/assets/BotHomePage.png new file mode 100644 index 0000000..804366a Binary files /dev/null and b/docs/bot/.gitbook/assets/BotHomePage.png differ diff --git a/docs/bot/.gitbook/assets/BotOptions.png b/docs/bot/.gitbook/assets/BotOptions.png new file mode 100644 index 0000000..0c76027 Binary files /dev/null and b/docs/bot/.gitbook/assets/BotOptions.png differ diff --git a/docs/bot/.gitbook/assets/BotOptionsComplete.png b/docs/bot/.gitbook/assets/BotOptionsComplete.png new file mode 100644 index 0000000..5747110 Binary files /dev/null and b/docs/bot/.gitbook/assets/BotOptionsComplete.png differ diff --git a/docs/bot/.gitbook/assets/BotPage.png b/docs/bot/.gitbook/assets/BotPage.png new file mode 100644 index 0000000..d9cf229 Binary files /dev/null and b/docs/bot/.gitbook/assets/BotPage.png differ diff --git a/docs/bot/.gitbook/assets/BotTab.png b/docs/bot/.gitbook/assets/BotTab.png new file mode 100644 index 0000000..74ed6a5 Binary files /dev/null and b/docs/bot/.gitbook/assets/BotTab.png differ diff --git a/docs/bot/.gitbook/assets/BotToken.png b/docs/bot/.gitbook/assets/BotToken.png new file mode 100644 index 0000000..25ea626 Binary files /dev/null and b/docs/bot/.gitbook/assets/BotToken.png differ diff --git a/docs/bot/.gitbook/assets/ClientIDCopy.png b/docs/bot/.gitbook/assets/ClientIDCopy.png new file mode 100644 index 0000000..098f3be Binary files /dev/null and b/docs/bot/.gitbook/assets/ClientIDCopy.png differ diff --git a/docs/bot/.gitbook/assets/CopyChannelID.png b/docs/bot/.gitbook/assets/CopyChannelID.png new file mode 100644 index 0000000..296be88 Binary files /dev/null and b/docs/bot/.gitbook/assets/CopyChannelID.png differ diff --git a/docs/bot/.gitbook/assets/CopyGeneratedOAuth2URL.png b/docs/bot/.gitbook/assets/CopyGeneratedOAuth2URL.png new file mode 100644 index 0000000..5049b16 Binary files /dev/null and b/docs/bot/.gitbook/assets/CopyGeneratedOAuth2URL.png differ diff --git a/docs/bot/.gitbook/assets/CopyRoleID.png b/docs/bot/.gitbook/assets/CopyRoleID.png new file mode 100644 index 0000000..a92d561 Binary files /dev/null and b/docs/bot/.gitbook/assets/CopyRoleID.png differ diff --git a/docs/bot/.gitbook/assets/CopyServerID.png b/docs/bot/.gitbook/assets/CopyServerID.png new file mode 100644 index 0000000..e38debb Binary files /dev/null and b/docs/bot/.gitbook/assets/CopyServerID.png differ diff --git a/docs/bot/.gitbook/assets/CreateApplicationDialogue.png b/docs/bot/.gitbook/assets/CreateApplicationDialogue.png new file mode 100644 index 0000000..b107427 Binary files /dev/null and b/docs/bot/.gitbook/assets/CreateApplicationDialogue.png differ diff --git a/docs/bot/.gitbook/assets/DeveloperModeToggle.png b/docs/bot/.gitbook/assets/DeveloperModeToggle.png new file mode 100644 index 0000000..a758433 Binary files /dev/null and b/docs/bot/.gitbook/assets/DeveloperModeToggle.png differ diff --git a/docs/bot/.gitbook/assets/DiscordApplicationsPage.png b/docs/bot/.gitbook/assets/DiscordApplicationsPage.png new file mode 100644 index 0000000..bef5f3c Binary files /dev/null and b/docs/bot/.gitbook/assets/DiscordApplicationsPage.png differ diff --git a/docs/bot/.gitbook/assets/DiscordApplicationsPageMarkedUp.png b/docs/bot/.gitbook/assets/DiscordApplicationsPageMarkedUp.png new file mode 100644 index 0000000..1e52cb3 Binary files /dev/null and b/docs/bot/.gitbook/assets/DiscordApplicationsPageMarkedUp.png differ diff --git a/docs/bot/.gitbook/assets/DockerDownloadPageMarkedUp.png b/docs/bot/.gitbook/assets/DockerDownloadPageMarkedUp.png new file mode 100644 index 0000000..4929f7e Binary files /dev/null and b/docs/bot/.gitbook/assets/DockerDownloadPageMarkedUp.png differ diff --git a/docs/bot/.gitbook/assets/DockerDownloadPageWindows.png b/docs/bot/.gitbook/assets/DockerDownloadPageWindows.png new file mode 100644 index 0000000..b2fd66a Binary files /dev/null and b/docs/bot/.gitbook/assets/DockerDownloadPageWindows.png differ diff --git a/docs/bot/.gitbook/assets/GitWindowsDownloadPage.png b/docs/bot/.gitbook/assets/GitWindowsDownloadPage.png new file mode 100644 index 0000000..9d344dc Binary files /dev/null and b/docs/bot/.gitbook/assets/GitWindowsDownloadPage.png differ diff --git a/docs/bot/.gitbook/assets/GitWindowsDownloadPageMarkedUp.png b/docs/bot/.gitbook/assets/GitWindowsDownloadPageMarkedUp.png new file mode 100644 index 0000000..9f6af90 Binary files /dev/null and b/docs/bot/.gitbook/assets/GitWindowsDownloadPageMarkedUp.png differ diff --git a/docs/bot/.gitbook/assets/InstallationCompleteOptions.png b/docs/bot/.gitbook/assets/InstallationCompleteOptions.png new file mode 100644 index 0000000..ffb1c46 Binary files /dev/null and b/docs/bot/.gitbook/assets/InstallationCompleteOptions.png differ diff --git a/docs/bot/.gitbook/assets/InstallationPage.png b/docs/bot/.gitbook/assets/InstallationPage.png new file mode 100644 index 0000000..b45ef5b Binary files /dev/null and b/docs/bot/.gitbook/assets/InstallationPage.png differ diff --git a/docs/bot/.gitbook/assets/InstallationTab.png b/docs/bot/.gitbook/assets/InstallationTab.png new file mode 100644 index 0000000..b751d77 Binary files /dev/null and b/docs/bot/.gitbook/assets/InstallationTab.png differ diff --git a/docs/bot/.gitbook/assets/InviteBotServerSelect.png b/docs/bot/.gitbook/assets/InviteBotServerSelect.png new file mode 100644 index 0000000..4c2c0ec Binary files /dev/null and b/docs/bot/.gitbook/assets/InviteBotServerSelect.png differ diff --git a/docs/bot/.gitbook/assets/InviteDiscordBotDialogue.png b/docs/bot/.gitbook/assets/InviteDiscordBotDialogue.png new file mode 100644 index 0000000..6f9a0e8 Binary files /dev/null and b/docs/bot/.gitbook/assets/InviteDiscordBotDialogue.png differ diff --git a/docs/bot/.gitbook/assets/OAuth2Page.png b/docs/bot/.gitbook/assets/OAuth2Page.png new file mode 100644 index 0000000..5225da4 Binary files /dev/null and b/docs/bot/.gitbook/assets/OAuth2Page.png differ diff --git a/docs/bot/.gitbook/assets/OAuth2Tab.png b/docs/bot/.gitbook/assets/OAuth2Tab.png new file mode 100644 index 0000000..4aae717 Binary files /dev/null and b/docs/bot/.gitbook/assets/OAuth2Tab.png differ diff --git a/docs/bot/.gitbook/assets/OAuth2URLGenerator.png b/docs/bot/.gitbook/assets/OAuth2URLGenerator.png new file mode 100644 index 0000000..5b62ca1 Binary files /dev/null and b/docs/bot/.gitbook/assets/OAuth2URLGenerator.png differ diff --git a/docs/bot/.gitbook/assets/OAuth2URLGeneratorConfiguration.png b/docs/bot/.gitbook/assets/OAuth2URLGeneratorConfiguration.png new file mode 100644 index 0000000..e59578f Binary files /dev/null and b/docs/bot/.gitbook/assets/OAuth2URLGeneratorConfiguration.png differ diff --git a/docs/bot/.gitbook/assets/ResetBotTokenButton.png b/docs/bot/.gitbook/assets/ResetBotTokenButton.png new file mode 100644 index 0000000..35a7271 Binary files /dev/null and b/docs/bot/.gitbook/assets/ResetBotTokenButton.png differ diff --git a/docs/bot/.gitbook/assets/ResetBotTokenDialogue.png b/docs/bot/.gitbook/assets/ResetBotTokenDialogue.png new file mode 100644 index 0000000..e3531ba Binary files /dev/null and b/docs/bot/.gitbook/assets/ResetBotTokenDialogue.png differ diff --git a/docs/bot/.gitbook/assets/ServerSettings.png b/docs/bot/.gitbook/assets/ServerSettings.png new file mode 100644 index 0000000..98435c0 Binary files /dev/null and b/docs/bot/.gitbook/assets/ServerSettings.png differ diff --git a/docs/bot/.gitbook/assets/SettingsIcon.png b/docs/bot/.gitbook/assets/SettingsIcon.png new file mode 100644 index 0000000..18c4efa Binary files /dev/null and b/docs/bot/.gitbook/assets/SettingsIcon.png differ diff --git a/docs/bot/README.md b/docs/bot/README.md new file mode 100644 index 0000000..bf9df2e --- /dev/null +++ b/docs/bot/README.md @@ -0,0 +1,30 @@ +--- +icon: house +layout: + title: + visible: true + description: + visible: false + tableOfContents: + visible: true + outline: + visible: true + pagination: + visible: true +--- + +# Home + +{% hint style="warning" %} +This documentation is still under construction. Expect rough edges and undocumented features +{% endhint %} + +{% hint style="info" %} +This documentation is meant for people who want to use Poixpixel's Discord Bot on their own Discord server +{% endhint %} + +Welcome to the Poixpixel Discord Bot's documentation! Here, you'll find information on getting started with the bot as well as documentation on all of its commands and features. + +### Jump right in + +
Getting StartedGet started by setting up the botBroken link
BasicsLearn the basics of the botBroken link
Contributing & DevelopmentInformation for developers and contributorsBroken link
diff --git a/docs/bot/SUMMARY.md b/docs/bot/SUMMARY.md new file mode 100644 index 0000000..4d78b18 --- /dev/null +++ b/docs/bot/SUMMARY.md @@ -0,0 +1,21 @@ +# Table of contents + +* [Home](README.md) + +## Getting Started + +* [Quickstart](getting-started/quickstart/README.md) + * [Self-Hosting](getting-started/quickstart/self-hosting.md) + * [Using a Cloud Provider](getting-started/quickstart/using-a-cloud-provider.md) +* [Basic Configuration](getting-started/basic-configuration.md) + +## Basics + +* [Updating the Bot](basics/updating-the-bot.md) +* [Configuration Options](basics/configuration-options.md) + +## Developers + +* [Introduction](developers/introduction.md) +* [Contribution Guidelines](developers/contribution-guidelines.md) +* [Licensing Information](developers/licensing-information.md) diff --git a/docs/bot/basics/configuration-options.md b/docs/bot/basics/configuration-options.md new file mode 100644 index 0000000..89788a7 --- /dev/null +++ b/docs/bot/basics/configuration-options.md @@ -0,0 +1,7 @@ +--- +description: Learn about all the configuration options and what they do +icon: list +--- + +# Configuration Options + diff --git a/docs/bot/basics/updating-the-bot.md b/docs/bot/basics/updating-the-bot.md new file mode 100644 index 0000000..e5db6f5 --- /dev/null +++ b/docs/bot/basics/updating-the-bot.md @@ -0,0 +1,6 @@ +--- +icon: pen-to-square +--- + +# Updating the Bot + diff --git a/docs/bot/developers/contribution-guidelines.md b/docs/bot/developers/contribution-guidelines.md new file mode 100644 index 0000000..90e34e7 --- /dev/null +++ b/docs/bot/developers/contribution-guidelines.md @@ -0,0 +1,6 @@ +--- +icon: clipboard-list +--- + +# Contribution Guidelines + diff --git a/docs/bot/developers/introduction.md b/docs/bot/developers/introduction.md new file mode 100644 index 0000000..37c78de --- /dev/null +++ b/docs/bot/developers/introduction.md @@ -0,0 +1,6 @@ +--- +icon: signs-post +--- + +# Introduction + diff --git a/docs/bot/developers/licensing-information.md b/docs/bot/developers/licensing-information.md new file mode 100644 index 0000000..601671f --- /dev/null +++ b/docs/bot/developers/licensing-information.md @@ -0,0 +1,17 @@ +--- +icon: scale-unbalanced +layout: + title: + visible: true + description: + visible: false + tableOfContents: + visible: true + outline: + visible: true + pagination: + visible: true +--- + +# Licensing Information + diff --git a/docs/bot/getting-started/basic-configuration.md b/docs/bot/getting-started/basic-configuration.md new file mode 100644 index 0000000..33b8409 --- /dev/null +++ b/docs/bot/getting-started/basic-configuration.md @@ -0,0 +1,7 @@ +--- +description: Basic bot configuration options +icon: sliders +--- + +# Basic Configuration + diff --git a/docs/bot/getting-started/quickstart/README.md b/docs/bot/getting-started/quickstart/README.md new file mode 100644 index 0000000..7a07812 --- /dev/null +++ b/docs/bot/getting-started/quickstart/README.md @@ -0,0 +1,216 @@ +--- +description: Get started with the Discord bot +icon: bullseye-arrow +--- + +# Quickstart + +## Requirements + +* **A Database & Cache**: Use **Valkey** or **Redis** for caching (we use Valkey in this guide). The main database must be **PostgreSQL**. +* **Server**: A server or computer to host the bot, preferably running Linux. +* **Skills**: Basic knowledge of the command line and managing servers. +* **Permissions**: The **Manage Server** permission in the Discord server where you want to add the bot. +* **Discord Developer Dashboard** access. +* A Discord account (obviously). + +## Step 0: Chose Your Hosting Method + +You can either choose to host everything yourself, or you can use a cloud provider to hose everything for you. + +{% hint style="info" %} +We recommend hosting everything in the cloud since it's easier for beginners. However, if you have a spare computer or server and the knowledge to host it yourself, we suggest you do that since its cheaper than paying a cloud provider to host it all for you. +{% endhint %} + +## Step 1: Basic Setup and Preparation + +After deciding on how you want to host the bot and its resources, move onto the basic setup and preparation outlined below. + +{% stepper %} +{% step %} +### Navigate to the Discord Developer Dashboard + +[Click this link](https://discord.com/developers/applications) and sign into your discord account. Once you sign in, you should see a page like this: + +
Applications page of the Discord Developer Dashboard

Discord Developer Dashboard Applications Page

+ +This is what's known as your applications page. This is where you'll see and manage all of your Discord bots and applications. +{% endstep %} + +{% step %} +### Create a new application + +Click the button that says, "New Application". + +
Red arrow pointing to button on left navigation pane that says "New Application"

Create a New Application

+ +After clicking the button, give you Discord Bot a name, click the check box, and then click "Create". + +
Create application dialogue

Create Application Dialogue

+ +Once you click the "Create" button and complete the CAPTCHA, you should see a page like this: + +
Discord application overview page

Discord Application Overview Page

+ +This is the overview page for your Discord bot. Here, you can configure the app icon, the app name, and app description. +{% endstep %} + +{% step %} +### Invite the bot to your server + +In the left navigation pane, click the button that says, "OAuth2". + +
Red arrow pointing to button on left navigation pane that says "OAuth2"

OAuth2 Button

+ +Once you click the button, you should see a page that looks like this: + +
OAuth2 Page

OAuth2 Page

+ +Underneath the section that says, "Client Information" where it says "Client ID", click on the "Copy" button. Save this number as we'll need it for later. + +
Arrow pointing to "Copy" button under Client ID section

Client ID

+ +Next, scroll down to this section: + +
OAuth2 URL Generator

OAuth2 URL Generator

+ +Check the checkbox next to where it says, "bot". Scroll down. Under the "Bot Permissions" section, click the checkbox for "Administrator" under the "General Permissions" section. Next, scroll down again and for the "Intergration Type" dropdown, make sure it says, "Guild Install". In the end, your configuration should look something like this: + +
OAuth2 URL Generator Configuration Options

OAuth2 URL Generator Configuration

+ +Click "Copy" next to "Generated URL".\ + + +
Copy generated URL

Copy Generated URL

+ +Open a new browser tab, pase in the link, and press Enter. You should then see a screen where you can invite the bot into a Discord server. Select your Discord server from the dropdown menu and click "continue". + +{% hint style="info" %} +If you don't see the server you want to add the bot to, it's probably because you don't have the **Manage Server** permission in that Discord server +{% endhint %} + +
Invite discord bot to server dialogue

Invite Discord Bot Dialogue

+ +On the next screen, click "Authorize" and if prompted, complete multifactor authentication and the CAPTCHA. + +
Authorize Discord Bot

Authorize Discord Bot

+ +If everything was successful, you should see a success message like the one below. + +
Discord bot added successfully message

Success Message

+ +The discord bot was successfully added to your selected Discord server. You can now continue with the rest of the guide. +{% endstep %} + +{% step %} +### Configure installation settings + +Click the button on the left navigation pane that says, "Installation". + +
Red arrow pointing to button on left navigation pane that says "Installation"

Installation Button

+ +After you click on the button, you'll be greeted by a page that look something like this: + +
Installation page

Installation Page

+ +First, uncheck the checkbox next to "User Install". Next, select "None" from the "Install Link" dropdown (click where it says "Discord Provided Link"). Finally, click "Save" at the bottom of the screen. When you're done, your screen should look like this: + +
Updated installation options

Updated Installation Options

+ +Now, it's time to configure the actual Discord bot. +{% endstep %} + +{% step %} +### Configure and get your bot's information + +Click the button on the left navigation pane that says, "Bot". + +
Red arrow pointing to button on left navigation pane that says "Bot"
+ +After clicking on the "Bot" tab, you'll see a page like this: + +
Discord application bot tab

Discord Application Bot Tab

+ +Here, you can configure things such as the username, banner, and icon of your bot. Scroll down to the section that looks like this: + +
Discord bot options

Bot Options

+ +Deselect the "Public Bot" option and choose all other options. Make sure to click "Save". Your screen should resemble this: + +
Updated bot options

Updated Bot Options

+ +{% hint style="info" %} +**Explanation for Selected Options:** + +* Unselecting "Public Bot" restricts adding the bot to a server specifically to you, which is our intention. +* Enabling "Requires OAuth2 Code Grant" ensures the bot receives all its permissions before entering your server. +* By selecting all options under "Privileged Gateway Intents," the bot can view member presence statuses, manage members, and access message content. +{% endhint %} + +Next, scroll back up to this section: + +
Bot details and token configuration options

Basic Bot Configuration and Token Options

+ +Underneath the "Token" header, click on the button that says, "Reset Token". + +
Reset bot token button

Reset Token Button

+ +Click "Yes, do it!" on the dialogue that pops up. + +
Reset bot token confirmation dialogue

Reset Bot Token Dialogue

+ +Follow the multifactor authentication steps, and once complete, you should see a screen like this: + +{% hint style="danger" %} +**WARNING: DO NOT SHARE YOUR BOT TOKEN WITH ANYONE. Treat your bot token like a password. If someone gets access to your bot's token, they'll have unrestricted access to your bot and Discord server, meaning they can do anything that they want. Store this token in a safe place as you won't get to see it again and will have to regenerate it.** +{% endhint %} + +
Discord bot token

Discord Bot Token

+ +Copy your bot token and save it somewhere safe. We'll need it later. +{% endstep %} + +{% step %} +### Gather other information + +If you've made it this far without getting lost, give yourself a pat on the back. Before we move onto the fun stuff, we have to gather one some last bits of information from our Discord server. + +Head on over to [Discord](https://discord.com/app) and click on the settings icon next to your username. + +
Red arrow pointing to settings button

Settings Icon

+ +Next, scroll down on the left navigation pane and click "Advanced". + +
Red arrow pointing to button on left navigation pane that says "Advanced"

Advanced Settings Button

+ +Find the option that says, "Developer Mode" and turn that on. Once you are done, your screen should look like this: + +
Developer mode toggle on

Developer Mode Toggle Turned On

+ +Exit settings and navigate to your Discord server. On the left server selector pane, right click on your Discord server and click the "Copy Server ID" button on the bottom of the options menu. This is what's known as your "Guild ID". Save this ID as we'll need it later. + +
Red arrow pointing to button that says "Copy Server ID"

Copy Server ID Button

+ +Next, in your Discord server, right click on your logs channel and click the "Copy Channel ID" button. Repeat this for your welcome channel. + +
Red arrow pointing to button that says "Copy Channel ID"

Copy Channel ID Button

+ +Lastly, click on your server's name at the top and click on "Server Settings". + +
Server settings button

Server Settings Button

+ +Then, click on the "Roles" button in the left navigation pane and find the role(s) that you want to assign to people as soon as they join your server. Right click on each role and select "Copy Role ID". Save these ID's as we'll need them later when configuring the bot. + +
Copy role ID for join roles

Copy Join Role ID

+ +We are now done with the preparation for our Discord bot. It's now time to setup and deploy the Discord bot and its services. Based on your decision in Step 0, click on the corresponding link to take you to the rest of the quick start guide. +{% endstep %} +{% endstepper %} + +{% content-ref url="self-hosting.md" %} +[self-hosting.md](self-hosting.md) +{% endcontent-ref %} + +{% content-ref url="using-a-cloud-provider.md" %} +[using-a-cloud-provider.md](using-a-cloud-provider.md) +{% endcontent-ref %} diff --git a/docs/bot/getting-started/quickstart/self-hosting.md b/docs/bot/getting-started/quickstart/self-hosting.md new file mode 100644 index 0000000..c487fa4 --- /dev/null +++ b/docs/bot/getting-started/quickstart/self-hosting.md @@ -0,0 +1,655 @@ +--- +description: Host the bot and its services yourself on a machine you own +icon: server +--- + +# Self-Hosting + +## Step 2: Prepare your server + +To set up the bot and its services, we first need to prepare our server. The steps may vary slightly depending on your server's operating system. Choose the instructions that match your OS. This guide assumes you have basic command line and server-managing skills. + +
+ +MacOS/Linux Instructions + +## For Debian based Linux distributions using the x86-64 architecture + +First, let's update our package lists and upgrade our existing packages. + +{% code fullWidth="false" %} +```bash +# Update package lists: +sudo apt-get update + +# Upgrade existing packages: +sudo apt-get upgrade -y +``` +{% endcode %} + +Next, let's install Node.js and the Yarn package manager (this is mostly copied and pasted from [https://nodejs.org/en/download](https://nodejs.org/en/download)). + +```bash +# Download and install fnm: +curl -o- https://fnm.vercel.app/install | bash + +# Activate fnm in our current shell: +source ~/.bashrc + +# Download and install Node.js: +fnm install 22 + +# Verify the Node.js version: +node -v # Should print "v22.14.0". + +# Download and install Yarn: +corepack enable yarn + +# Verify Yarn version: +yarn -v +``` + +Now, let's install git. This will make it so that we can clone the GitHub repository to our local machine (taken from [https://git-scm.com/downloads/linux](https://git-scm.com/downloads/linux)). + +```bash +# Install git using built-in package manager: +sudo apt-get install git + +# Verify git version: +git --version +``` + +Let's also install OpenSSL as we'll need it to generate SSL certificates for our PostgreSQL and caching databases: + +```bash +# Install OpenSSL: +sudo apt install openssl + +# Verify OpenSSL version: +openssl version +``` + +Next, let's install the pm2 process manager. This is how we'll run the Discord bot in a production environment (instructions from [https://pm2.keymetrics.io/docs/usage/quick-start/](https://pm2.keymetrics.io/docs/usage/quick-start/)). + +```bash +# Install pm2 using npm +npm install pm2@latest -g + +# or install pm2 using yarn: +yarn global add pm2 + +# Verify pm2 version: +pm2 -v +``` + +Finally, let's install Docker and Docker Compose. These tools enable us to efficiently manage our PostgreSQL and caching databases with added flexibility, reducing complex configurations and simplifying overall management. Instructions are taken from here: [https://docs.docker.com/engine/install/debian/](https://docs.docker.com/engine/install/debian/) (if you're using Ubuntu, you can go straight to here: [https://docs.docker.com/engine/install/ubuntu/](https://docs.docker.com/engine/install/ubuntu/)) and here: [https://docs.docker.com/engine/install/linux-postinstall/](https://docs.docker.com/engine/install/linux-postinstall/). + +```bash +# Uninstall conflicting packages: +for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do sudo apt-get remove $pkg; done + +# Clean up unused packages: +sudo apt autoremove + +# Add Docker's official GPG key: +sudo apt-get update +sudo apt-get install ca-certificates curl +sudo install -m 0755 -d /etc/apt/keyrings +sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc +sudo chmod a+r /etc/apt/keyrings/docker.asc + +# Add the repository to Apt sources (if you get errors on this step, check the official Docker documentation): +echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \ + $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ + sudo tee /etc/apt/sources.list.d/docker.list > /dev/null +sudo apt-get update + +# Install the latest version of Docker and its tools: +sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin + +# Start Docker (if it's not already running): +sudo systemctl start docker + +# Configure Docker to start on boot: +sudo systemctl enable docker.service +sudo systemctl enable containerd.service + +# Make it so we don't have to use sudo to use Docker: +sudo groupadd docker +sudo usermod -aG docker $USER +# Either log out and log back into your user account or run this command: +newgrp docker + +# If you get this error: +# WARNING: Error loading config file: /home/user/.docker/config.json - +# stat /home/user/.docker/config.json: permission denied +# Run these commands: +sudo chown "$USER":"$USER" /home/"$USER"/.docker -R +sudo chmod g+rwx "$HOME/.docker" -R + +# Verify Docker version: +docker version + +# Verify Docker Compose Version: +docker compose version + +# Run an example Docker container to ensure it's working (this should print a whole bunch of information): +docker run hello-world +``` + +With all necessary tools installed and configured, we're ready to run the bot and its services. + +
+ +
+ +Windows Instructions + +First, let's install Node.js and the Yarn package manager on our machine. Open PowerShell and type in the following commands (instructions taken from [https://nodejs.org/en/download](https://nodejs.org/en/download)): + +```powershell +# Download and install fnm (you might have to restart your terminal to use fnm): +winget install Schniz.fnm + +# Download and install Node.js: +fnm install 22 + +# Verify the Node.js version: +node -v # Should print "v22.14.0". + +# Download and install Yarn: +corepack enable yarn + +# Verify Yarn version: +yarn -v +``` + +Next, let's install git. This will enable us to clone the GitHub repository to our local machine. + +Open a web browser and head to [https://git-scm.com/downloads/win](https://git-scm.com/downloads/win). You should see a page like this: + +
Download page for git

Git Download Page

+ +Underneath the "Standalone Installer" section, click on the link that says "64-bit Git for Windows Setup." + +
Red arrow pointing to a link that says "64-bit Git for Windows Setup."

Git Download Page

+ +This should download the git installer to your machine. Once it's finished downloading, open the installer and follow the steps to install git. After git is finished installing, open PowerShell again and type in the following to verify that git was installed correctly: + +```powershell +# Verify git version (you might have to relaunch your terminal if you're using and already open window): +git --version +``` + +Now, let's install the pm2 process manager. This is how we'll run the Discord bot in a production environment (instructions from [https://pm2.keymetrics.io/docs/usage/quick-start/](https://pm2.keymetrics.io/docs/usage/quick-start/)). Run the following in PowerShell: + +```powershell +# Install pm2 using npm: +npm install pm2@latest -g + +# or install pm2 using yarn: +yarn global add pm2 + +# Verify pm2 version: +pm2 -v +``` + +Finally, let's install Docker and Docker compose. These tools will make it so that we can run our PostgreSQL and caching databases without needing a separate Linux server to run them. All instructions are taken from here [https://medium.com/@piyushkashyap045/comprehensive-guide-installing-docker-and-docker-compose-on-windows-linux-and-macos-a022cf82ac0b](https://medium.com/@piyushkashyap045/comprehensive-guide-installing-docker-and-docker-compose-on-windows-linux-and-macos-a022cf82ac0b) and here [https://docs.docker.com/desktop/setup/install/windows-install/](https://docs.docker.com/desktop/setup/install/windows-install/). + +First, open a PowerShell window as an Administrator and type in the following to install the WSL runtime: + +```powershell +# Install the WSL runtime: +wsl --install +``` + +After the installation is complete, restart your computer. + +Next, open a web browser and go to [https://docs.docker.com/desktop/setup/install/windows-install/](https://docs.docker.com/desktop/setup/install/windows-install/). You should see a page like this: + +
Docker download page for Windows

Docker Download Page for Windows

+ +Click on the button that says, "Docker Desktop for Windows - x86\_64". + +
Red arrow pointing to a button that says "Docker Desktop for Windows - x86_64"

Docker Download Page for Windows

+ +This should download the Docker installer to your machine. Once it's finished downloading, open the installer and follow the instructions to install Docker onto your machine. If it prompts you to use either Hyper-V or WSL for containers, choose WSL. Once the installation is finished, restart your computer. + + Finally, open your search bar and search for Docker. Click on the option that says, "Docker Desktop". Wait for Docker to start. Once it starts up, open a new PowerShell window and type in the following: + +```powershell +# Verify Docker version: +docker --version + +# Verify Docker Compose version: +docker compose version + +# Run an example Docker container to ensure it's working (this should print a whole bunch of information): +docker run hello-world +``` + +With all necessary tools installed and configured, we're ready to run the bot and its services. + +
+ +## Step 3: Setup and configure the bot + +Once our server is prepared, we can proceed to download and configure the bot by following these steps: + +{% stepper %} +{% step %} +### Clone the bot's repository to your server + +Open a terminal window and paste in the following: + +```bash +# Clone the GitHub repository to our local machine: +git clone -b main --single-branch --depth 1 https://github.com/ahmadk953/poixpixel-discord-bot.git + +# Change-Directory into the bot's directory: +cd poixpixel-discord-bot +``` + +Now, we can start configuring the bot +{% endstep %} + +{% step %} +### Configure the bot + +Run the following to copy the `config.example.json` file to a new file named `config.json`. This is where we'll store all our bot configuration options, including the bot's token. + +```bash +# Copy and rename config.example.json to config.json: +cp config.example.json config.json +``` + +Next, open the new `config.json` file in a text editor like vim or nano on Mac/Linux, or Visual Studio Code on Windows. When you open the file, it should look something like this: + +```json +{ + "token": "DISCORD_BOT_TOKEN", + "clientId": "DISCORD_BOT_ID", + "guildId": "DISCORD_SERVER_ID", + "database": { + "dbConnectionString": "POSTGRESQL_CONNECTION_STRING", + "maxRetryAttempts": "MAX_RETRY_ATTEMPTS", + "retryDelay": "RETRY_DELAY_IN_MS" + }, + "redis": { + "redisConnectionString": "REDIS_CONNECTION_STRING", + "retryAttempts": "RETRY_ATTEMPTS", + "initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS" + }, + "channels": { + "welcome": "WELCOME_CHANNEL_ID", + "logs": "LOG_CHANNEL_ID", + "counting": "COUNTING_CHANNEL_ID", + "factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID", + "factApproval": "FACT_APPROVAL_CHANNEL_ID", + "advancements": "ADVANCEMENTS_CHANNEL_ID" + }, + "roles": { + "joinRoles": [ + "JOIN_ROLE_IDS" + ], + "levelRoles": [ + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + }, + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + }, + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + } + ], + "staffRoles": [ + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + }, + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + }, + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + } + ], + "factPingRole": "FACT_OF_THE_DAY_ROLE_ID" + }, + "leveling": { + "xpCooldown": "XP_COOLDOWN_IN_SECONDS", + "minXpAwarded": "MINIMUM_XP_AWARDED", + "maxXpAwarded": "MAXIMUM_XP_AWARDED" + } +} +``` + +To configure your bot, follow these steps: + +1. Replace `DISCORD_BOT_TOKEN` with your bot's token. +2. Replace `DISCORD_BOT_ID` with your bot's Client ID. +3. Replace `DISCORD_SERVER_ID` with your server's ID. +4. Replace the following with corresponding IDs you've collected: + * `WELCOME_CHANNEL_ID` + * `LOG_CHANNEL_ID` + * `JOIN_ROLE_IDS` + +After completing these replacements, your configuration should look like this: + +```json +{ + "token": "MTM*****************************", + "clientId": "1361135180999561387", + "guildId": "1237197296714907678", + "database": { + "dbConnectionString": "POSTGRESQL_CONNECTION_STRING", + "maxRetryAttempts": "MAX_RETRY_ATTEMPTS", + "retryDelay": "RETRY_DELAY_IN_MS" + }, + "redis": { + "redisConnectionString": "REDIS_CONNECTION_STRING", + "retryAttempts": "RETRY_ATTEMPTS", + "initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS" + }, + "channels": { + "welcome": "1361198468340912319", + "logs": "1361198724864540693", + "counting": "COUNTING_CHANNEL_ID", + "factOfTheDay": "FACT_OF_THE_DAY_CHANNEL_ID", + "factApproval": "FACT_APPROVAL_CHANNEL_ID", + "advancements": "ADVANCEMENTS_CHANNEL_ID" + }, + "roles": { + "joinRoles": [ + "1361197760530874528", + "1362199066343375103" + ], + "levelRoles": [ + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + }, + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + }, + { + "level": "LEVEL_NUMBER", + "roleId": "ROLE_ID" + } + ], + "staffRoles": [ + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + }, + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + }, + { + "name": "ROLE_NAME", + "roleId": "ROLE_ID" + } + ], + "factPingRole": "FACT_OF_THE_DAY_ROLE_ID" + }, + "leveling": { + "xpCooldown": "XP_COOLDOWN_IN_SECONDS", + "minXpAwarded": "MINIMUM_XP_AWARDED", + "maxXpAwarded": "MAXIMUM_XP_AWARDED" + } +} +``` + +We'll fill in the details for the caching and PostgreSQL databases later. Lastly, let's get the bot ready for when we eventually start it up. +{% endstep %} + +{% step %} +### Setup and compile the source code + +Open a terminal window in the projects root directory and run the following commands to install dependencies and compile the source code. + +```bash +# Install dependencies (if asked to continue, press 'y' and hit enter for yes): +yarn install --immutable + +# Invoke build tools: +yarn prepare + +# Compile source code: +yarn compile +``` + +Now that we've finished preparing the bot, let's move onto setting up its resources +{% endstep %} +{% endstepper %} + +## Step 4: Set up the PostgreSQL and caching databases + +Now, it's time to set up the bot's services. Follow the steps below to set up the PostgreSQL and caching databases. + +{% stepper %} +{% step %} +### Generate SSL certificates + +{% hint style="info" %} +Note that this step might be a little tricky on Windows and that it's not fully tested. If anyone would like to fully test and contribute their finding, that would be extremely helpful. +{% endhint %} + +If on Windows, type the following into your terminal. If you're on Linux/MacOS, you can skip this step. + +```bash +# Launch WSL: +wsl + +# Head into the directory where the bot's files are located: +cd /mnt/DRIVELETTER/PATH/TO/DISCORD/BOT/DIRECTORY +``` + +Make sure to replace `DRIVELETTER/ATH/TO/DISCORD/BOT/DIRECTORY` with the path to your bot's directory as you normally would. For example: + +```bash +cd /mnt/c/Users/ahmad/Downloads/poixpixel-discord-bot +``` + +There's already a shell script in the project's directory that'll generate the SSL certificates for you. Just run the following commands to execute the script: + +{% hint style="info" %} +Note that it's always a good idea to check scripts that you are about to execute from any source online for malicious code. If you don't understand what the script is doing or, don't know how to read bash scripts, you can always ask an AI tool to explain it for you. The source code for the script that we are about to execute can be found [here](../../../../generate-certs.sh). +{% endhint %} + +```bash +# Make the script executable: +chmod +x generate-certs.sh + +# Run the script: +./generate-certs.sh +``` + +{% hint style="info" %} +Note that these SSL certificates will expire after a year. Simply re-run the script to generate new ones and restart both the bot and the Docker containers. +{% endhint %} + +Now that we have the SSL certificates set up, we can move onto configuring environment variables for our Docker containers. Windows users can now switch back to their normal terminals by simply closing and re-opening their terminals. +{% endstep %} + +{% step %} +### Set up environment variables + +Run the following to copy the `.env.example` file to a new file named `.env`. This is where our database username and password, as well as our caching database's password will live. + +```bash +# Copy and rename .env.example to .env: +cp .env.example .env +``` + +Next, open the new `.env` file. You should see something like this: + +``` +POSTGRES_USER=your_postgres_user +POSTGRES_PASSWORD=your_postgres_password +POSTGRES_DB=your_database_name +VALKEY_PASSWORD=your_valkey_password +``` + +To configure your bot's resources, follow these steps: + +1. Replace `your_postgres_user` with your desired username for connecting to the database. +2. Replace `your_postgres_password` with a secure password for the database user you just specified. +3. Substitute `your_database_name` with a name for the Postgres database that will store your bot's data. +4. Replace `your_valkey_password` with a password for your Valkey database, which serves as a caching database. + +When you're done, your config file should look like this: + +``` +POSTGRES_USER=bot-user +POSTGRES_PASSWORD=password +POSTGRES_DB=bot-db +VALKEY_PASSWORD=password +``` + +With our bot's resources configured, let's launch their Docker containers. +{% endstep %} + +{% step %} +### Spin up the Docker containers + +This step is relatively simple; all you have to do is run the command below to start up the Docker containers + +```bash +# Spin up the Docker containers: +docker compose up -d +``` + +And in case you need to stop the containers: + +
# Stop the containers WITHOUT deleting and removing them:
+docker compose stop
+
+# Stop the containers and DELETE/REMOVE THEM. Note your DATA WILL BE SAFE. This just deletes the actual Docker containers:
+docker compose down
+
+ +Lastly, to restart the containers and view their logs, you can run the following commands: + +```bash +# Restart the Docker containers: +docker compose restart + +# View Docker container logs: +docker compose logs +``` + +Once all the bot's resources are operational, let's complete the configuration and start it up. +{% endstep %} +{% endstepper %} + +## Step 5: Finish bot configuration + +Now that we have the bot's resources up and running, let's finish configuring it. Open the `config.json` file again and scroll to the section that look like this: + +
"database": {
+    "dbConnectionString": "POSTGRESQL_CONNECTION_STRING",
+    "maxRetryAttempts": "MAX_RETRY_ATTEMPTS",
+    "retryDelay": "RETRY_DELAY_IN_MS"
+},
+
+ +You can use the following template to obtain the PostgreSQL connection string: `postgresql://username:password@localhost/database`. + +Since the bot and the Postgres instance run on the same server, the format should be: `postgresql://username:password@localhost/database`. + +Fill out the format as follows: + +1. Replace `username` with the value from the `POSTGRES_USER` environment variable. +2. Replace `password` with the value from the `POSTGRES_PASSWORD` environment variable. +3. Replace `database` with the value from the `POSTGRES_DB` environment variable. + +Once updated, it should look like this example: `postgresql://bot-user:password@localhost/bot-db`. Replace `POSTGRESQL_CONNECTION_STRING` with this value. + +Lastly, replace `MAX_RETRY_ATTEMPTS` with the desired maximum number of connection attempts. Then, change `RETRY_DELAY_IN_MS` to specify the wait time in milliseconds between retries. You should now have something that look like this: + +```json +"database": { + "dbConnectionString": "postgresql://bot-user:password@localhost/bot-db", + "maxRetryAttempts": "5", + "retryDelay": "2000" +}, +``` + +Next, scroll down to the section that looks like this: + +```json +"redis": { + "redisConnectionString": "REDIS_CONNECTION_STRING", + "retryAttempts": "RETRY_ATTEMPTS", + "initialRetryDelay": "INITIAL_RETRY_DELAY_IN_MS" +}, +``` + +You can use the following template to obtain the Redis (in our case, Valkey since they're compatible with each other) connection string: `redis://username:password@host:port`. + +In our case, this is the format we'll be using: `redis://default:password@localhost:6379`. All you have to do is replace `password` with the value you set for the `VALKEY_PASSWORD` environment variable. Replace `REDIS_CONNECTION_STRING` with this value. + +Lastly, replace `RETRY_ATTEMPTS` with the desired maximum number of connection attempts. Then, change `INITIAL_RETRY_DELAY_IN_MS` to specify the wait time in milliseconds between retries. You should now have something that look like this: + +```json +"redis": { + "redisConnectionString": "redis://default:password@localhost:6379", + "retryAttempts": "3", + "initialRetryDelay": "3000" +}, +``` + +Now, let's proceed to set up the tables in our database and start the bot. + +## Step 6: Set up database tables + +To set up the database tables, all you have to do is run the following commands: + +```bash +# Generate SQL migration file: +npx drizzle-kit generate + +# Apply SQL migration to database: +npx drizzle-kit migrate +``` + +Now, we can finally start the bot. + +## Step 7: Start the bot + +To start the bot, simply run the following: + +```bash +# Start the bot in production mode: +yarn start:prod +``` + +To view the bot's logs, type in this command: + +```bash +# To view bot logs: +pm2 logs +``` + +To stop the bot, run this command: + +```bash +# To stop the bot: +pm2 stop poixpixel-discord-bot +``` + +Finally, go to your Discord server and test out the bot's commands to make sure that it's working. Once you have verified that it is working, you can move onto reading about some of the bot's basic configuration options linked below. + +{% content-ref url="../basic-configuration.md" %} +[basic-configuration.md](../basic-configuration.md) +{% endcontent-ref %} diff --git a/docs/bot/getting-started/quickstart/using-a-cloud-provider.md b/docs/bot/getting-started/quickstart/using-a-cloud-provider.md new file mode 100644 index 0000000..0e55e68 --- /dev/null +++ b/docs/bot/getting-started/quickstart/using-a-cloud-provider.md @@ -0,0 +1,6 @@ +--- +icon: cloud +--- + +# Using a Cloud Provider + diff --git a/drizzle.config.ts b/drizzle.config.ts index 324e350..eb5083a 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,14 +1,30 @@ import fs from 'node:fs'; +import path from 'node:path'; import { defineConfig } from 'drizzle-kit'; const config = JSON.parse(fs.readFileSync('./config.json', 'utf8')); -const { dbConnectionString } = config; +const { database } = config; export default defineConfig({ out: './drizzle', schema: './src/db/schema.ts', dialect: 'postgresql', dbCredentials: { - url: dbConnectionString, + url: database.dbConnectionString, + ssl: (() => { + try { + return { + ca: fs.readFileSync(path.resolve('./certs/psql-ca.crt')), + key: fs.readFileSync(path.resolve('./certs/psql-client.key')), + cert: fs.readFileSync(path.resolve('./certs/psql-server.crt')), + }; + } catch (error) { + console.warn( + 'Failed to load certificates for database, using insecure connection:', + error, + ); + return undefined; + } + })(), }, }); diff --git a/generate-certs.sh b/generate-certs.sh new file mode 100755 index 0000000..e025695 --- /dev/null +++ b/generate-certs.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +# Get the Effective User ID +_uid="$(id -u)" + +# Create the certificates directory +mkdir -p certs + +# Generate PostgreSQL Certificates +openssl req -new -x509 -days 365 -nodes \ + -out certs/psql-server.crt \ + -keyout certs/psql-server.key \ + -subj "/CN=localhost" + +# Generate Valkey Certificates +openssl req -new -x509 -days 365 -nodes \ + -out certs/cache-server.crt \ + -keyout certs/cache-server.key \ + -subj "/CN=localhost" + +# Get CA Certificates +cp certs/psql-server.crt certs/psql-ca.crt +cp certs/cache-server.crt certs/cache-ca.crt + +# Setup Permissions +chmod 0600 certs/psql-server.key +chmod 0600 certs/cache-server.key + +# Assign Ownership +sudo chown 70:70 certs/psql-*.* +sudo chown 999:1000 certs/cache-*.* + +# Get Client Keys +sudo cp certs/psql-server.key certs/psql-client.key +sudo cp certs/cache-server.key certs/cache-client.key + +# Change Client Key Ownership +sudo chown $_uid:$_uid certs/psql-client.key +sudo chown $_uid:$_uid certs/cache-client.key + +# Change Client Key Permissions +sudo chmod +r certs/psql-client.key +sudo chmod +r certs/cache-client.key diff --git a/package.json b/package.json index df54c5e..4834b47 100644 --- a/package.json +++ b/package.json @@ -9,34 +9,46 @@ "scripts": { "compile": "npx tsc", "target": "node ./target/discord-bot.js", - "start": "yarn run compile && yarn run target", + "start:dev": "yarn run compile && yarn run target", + "start:dev:no-deploy": "cross-env SKIP_COMMAND_DEPLOY=true yarn run start:dev", + "start:prod": "yarn compile && pm2 start ./target/discord-bot.js --name poixpixel-discord-bot", + "restart": "pm2 restart poixpixel-discord-bot", + "undeploy-commands": "yarn compile && node --experimental-specifier-resolution=node ./target/util/undeployCommands.js", "lint": "npx eslint ./src && npx tsc --noEmit", "format": "prettier --check --ignore-path .prettierignore .", - "format:fix": "prettier --write --ignore-path .prettierignore ." + "format:fix": "prettier --write --ignore-path .prettierignore .", + "prepare": "ts-patch install -s && husky" }, "dependencies": { - "@napi-rs/canvas": "^0.1.68", - "discord.js": "^14.18.0", - "drizzle-orm": "^0.41.0", - "ioredis": "^5.6.0", - "pg": "^8.14.1" + "@napi-rs/canvas": "^0.1.69", + "discord.js": "^14.19.2", + "drizzle-orm": "^0.43.1", + "ioredis": "^5.6.1", + "pg": "^8.15.6" }, "devDependencies": { + "@commitlint/cli": "^19.8.0", + "@commitlint/config-conventional": "^19.8.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.23.0", + "@eslint/js": "^9.25.1", "@microsoft/eslint-formatter-sarif": "^3.1.0", - "@types/node": "^22.14.0", - "@types/pg": "^8.11.11", - "@typescript-eslint/eslint-plugin": "^8.29.0", - "@typescript-eslint/parser": "^8.29.0", - "drizzle-kit": "^0.30.6", - "eslint": "^9.23.0", - "eslint-config-prettier": "^10.1.1", + "@types/node": "^22.15.3", + "@types/pg": "^8.11.14", + "@typescript-eslint/eslint-plugin": "^8.31.1", + "@typescript-eslint/parser": "^8.31.1", + "cross-env": "^7.0.3", + "drizzle-kit": "^0.31.0", + "eslint": "^9.25.1", + "eslint-config-prettier": "^10.1.2", "globals": "^16.0.0", + "husky": "^9.1.7", + "lint-staged": "^15.5.1", "prettier": "3.5.3", "ts-node": "^10.9.2", - "tsx": "^4.19.3", - "typescript": "^5.8.2" + "ts-patch": "^3.3.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3", + "typescript-transform-paths": "^3.5.5" }, - "packageManager": "yarn@4.6.0" + "packageManager": "yarn@4.9.1" } diff --git a/src/commands/fun/achievement.ts b/src/commands/fun/achievement.ts new file mode 100644 index 0000000..c1ef61b --- /dev/null +++ b/src/commands/fun/achievement.ts @@ -0,0 +1,926 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + PermissionFlagsBits, + ChatInputCommandInteraction, + StringSelectMenuInteraction, + ComponentType, + ButtonInteraction, +} from 'discord.js'; + +import { + getAllAchievements, + getUserAchievements, + awardAchievement, + createAchievement, + deleteAchievement, + removeUserAchievement, +} from '@/db/db.js'; +import { announceAchievement } from '@/util/achievementManager.js'; +import { createPaginationButtons } from '@/util/helpers.js'; + +const command = { + data: new SlashCommandBuilder() + .setName('achievement') + .setDescription('Manage server achievements') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) + .addSubcommand((subcommand) => + subcommand + .setName('create') + .setDescription('Create a new achievement') + .addStringOption((option) => + option + .setName('name') + .setDescription('Name of the achievement') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('description') + .setDescription('Description of the achievement') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('requirement_type') + .setDescription('Type of requirement for this achievement') + .setRequired(true) + .addChoices( + { name: 'Message Count', value: 'message_count' }, + { name: 'Level', value: 'level' }, + { name: 'Reactions', value: 'reactions' }, + { name: 'Command Usage', value: 'command_usage' }, + ), + ) + .addIntegerOption((option) => + option + .setName('threshold') + .setDescription('Threshold value for completing the achievement') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('image_url') + .setDescription('URL for the achievement image (optional)') + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('command_name') + .setDescription('Command name (only for command_usage type)') + .setRequired(false), + ) + .addStringOption((option) => + option + .setName('reward_type') + .setDescription('Type of reward (optional)') + .setRequired(false) + .addChoices( + { name: 'XP', value: 'xp' }, + { name: 'Role', value: 'role' }, + ), + ) + .addStringOption((option) => + option + .setName('reward_value') + .setDescription('Value of the reward (XP amount or role ID)') + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('delete') + .setDescription('Delete an achievement') + .addIntegerOption((option) => + option + .setName('id') + .setDescription('ID of the achievement to delete') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('award') + .setDescription('Award an achievement to a user') + .addUserOption((option) => + option + .setName('user') + .setDescription('User to award the achievement to') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('achievement_id') + .setDescription('ID of the achievement to award') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('view') + .setDescription('View a users achievements') + .addUserOption((option) => + option + .setName('user') + .setDescription('User to view achievements for') + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('unaward') + .setDescription('Remove an achievement from a user') + .addUserOption((option) => + option + .setName('user') + .setDescription('User to remove the achievement from') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('achievement_id') + .setDescription('ID of the achievement to remove') + .setRequired(true), + ), + ), + + async execute(interaction: ChatInputCommandInteraction) { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply(); + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'create': + await handleCreateAchievement(interaction); + break; + case 'delete': + await handleDeleteAchievement(interaction); + break; + case 'award': + await handleAwardAchievement(interaction); + break; + case 'unaward': + await handleUnawardAchievement(interaction); + break; + case 'view': + await handleViewUserAchievements(interaction); + break; + } + }, +}; + +async function handleCreateAchievement( + interaction: ChatInputCommandInteraction, +) { + const name = interaction.options.getString('name')!; + const description = interaction.options.getString('description')!; + const imageUrl = interaction.options.getString('image_url'); + const requirementType = interaction.options.getString('requirement_type')!; + const threshold = interaction.options.getInteger('threshold')!; + const commandName = interaction.options.getString('command_name'); + const rewardType = interaction.options.getString('reward_type'); + const rewardValue = interaction.options.getString('reward_value'); + + if (requirementType === 'command_usage' && !commandName) { + await interaction.editReply( + 'Command name is required for command_usage type achievements.', + ); + return; + } + + if (rewardType && !rewardValue) { + await interaction.editReply( + `Reward value is required when setting a ${rewardType} reward.`, + ); + return; + } + + const requirement: any = {}; + if (requirementType === 'command_usage' && commandName) { + requirement.command = commandName; + } + + try { + const achievement = await createAchievement({ + name, + description, + imageUrl: imageUrl || undefined, + requirementType, + threshold, + requirement, + rewardType: rewardType || undefined, + rewardValue: rewardValue || undefined, + }); + + if (achievement) { + const embed = new EmbedBuilder() + .setColor(0x00ff00) + .setTitle('Achievement Created') + .setDescription(`Successfully created achievement: **${name}**`) + .addFields( + { name: 'ID', value: `${achievement.id}`, inline: true }, + { name: 'Type', value: requirementType, inline: true }, + { name: 'Threshold', value: `${threshold}`, inline: true }, + { name: 'Description', value: description }, + ); + + if (rewardType && rewardValue) { + embed.addFields({ + name: 'Reward', + value: `${rewardType === 'xp' ? `${rewardValue} XP` : `<@&${rewardValue}>`}`, + }); + } + + await interaction.editReply({ embeds: [embed] }); + } else { + await interaction.editReply('Failed to create achievement.'); + } + } catch (error) { + console.error('Error creating achievement:', error); + await interaction.editReply( + 'An error occurred while creating the achievement.', + ); + } +} + +async function handleDeleteAchievement( + interaction: ChatInputCommandInteraction, +) { + const achievementId = interaction.options.getInteger('id')!; + + try { + const success = await deleteAchievement(achievementId); + + if (success) { + await interaction.editReply( + `Achievement with ID ${achievementId} has been deleted.`, + ); + } else { + await interaction.editReply( + `Failed to delete achievement with ID ${achievementId}.`, + ); + } + } catch (error) { + console.error('Error deleting achievement:', error); + await interaction.editReply( + 'An error occurred while deleting the achievement.', + ); + } +} + +async function handleAwardAchievement( + interaction: ChatInputCommandInteraction, +) { + const user = interaction.options.getUser('user')!; + const achievementId = interaction.options.getInteger('achievement_id')!; + + try { + const allAchievements = await getAllAchievements(); + const achievement = allAchievements.find((a) => a.id === achievementId); + + if (!achievement) { + await interaction.editReply( + `Achievement with ID ${achievementId} not found.`, + ); + return; + } + + const success = await awardAchievement(user.id, achievementId); + + if (success) { + await announceAchievement(interaction.guild!, user.id, achievement); + await interaction.editReply( + `Achievement "${achievement.name}" awarded to ${user}.`, + ); + } else { + await interaction.editReply( + 'Failed to award achievement or user already has this achievement.', + ); + } + } catch (error) { + console.error('Error awarding achievement:', error); + await interaction.editReply( + 'An error occurred while awarding the achievement.', + ); + } +} + +async function handleViewUserAchievements( + interaction: ChatInputCommandInteraction, +) { + const targetUser = interaction.options.getUser('user') || interaction.user; + + try { + const userAchievements = await getUserAchievements(targetUser.id); + const allAchievements = await getAllAchievements(); + + const totalAchievements = allAchievements.length; + const earnedCount = userAchievements.filter((ua) => ua.earnedAt).length; + const overallProgress = + totalAchievements > 0 + ? Math.round((earnedCount / totalAchievements) * 100) + : 0; + + if (totalAchievements === 0) { + await interaction.editReply( + 'No achievements have been created on this server yet.', + ); + return; + } + + const earnedAchievements = userAchievements + .filter((ua) => { + return ( + ua.earnedAt && + ua.earnedAt !== null && + ua.earnedAt !== undefined && + new Date(ua.earnedAt).getTime() > 0 + ); + }) + .map((ua) => { + const achievementDef = allAchievements.find( + (a) => a.id === ua.achievementId, + ); + return { + ...ua, + definition: achievementDef, + }; + }) + .filter((a) => a.definition); + + const inProgressAchievements = userAchievements + .filter((ua) => { + return ( + (!ua.earnedAt || + ua.earnedAt === null || + ua.earnedAt === undefined || + new Date(ua.earnedAt).getTime() <= 0) && + (ua.progress ?? 0) > 0 + ); + }) + .map((ua) => { + const achievementDef = allAchievements.find( + (a) => a.id === ua.achievementId, + ); + return { + ...ua, + definition: achievementDef, + }; + }) + .filter((a) => a.definition); + + const earnedAndInProgressIds = new Set( + userAchievements + .filter( + (ua) => + (ua.progress ?? 0) > 0 || + (ua.earnedAt && new Date(ua.earnedAt).getTime() > 0), + ) + .map((ua) => ua.achievementId), + ); + const availableAchievements = allAchievements + .filter((a) => !earnedAndInProgressIds.has(a.id)) + .map((definition) => { + const existingEntry = userAchievements.find( + (ua) => + ua.achievementId === definition.id && + (ua.progress === 0 || ua.progress === null), + ); + + return { + achievementId: definition.id, + progress: existingEntry?.progress || 0, + definition, + }; + }); + + interface AchievementViewOption { + label: string; + value: string; + count: number; + } + + const options: AchievementViewOption[] = []; + + if (earnedAchievements.length > 0) { + options.push({ + label: 'Earned Achievements', + value: 'earned', + count: earnedAchievements.length, + }); + } + + if (inProgressAchievements.length > 0) { + options.push({ + label: 'In Progress', + value: 'progress', + count: inProgressAchievements.length, + }); + } + + if (availableAchievements.length > 0) { + options.push({ + label: 'Available Achievements', + value: 'available', + count: availableAchievements.length, + }); + } + + if (options.length === 0) { + await interaction.editReply('No achievement data found.'); + return; + } + + let initialOption = options[0].value; + for (const preferredType of ['earned', 'progress', 'available']) { + const found = options.find((opt) => opt.value === preferredType); + if (found) { + initialOption = preferredType; + break; + } + } + + const initialEmbedData = + initialOption === 'earned' + ? { achievements: earnedAchievements, title: 'Earned Achievements' } + : initialOption === 'progress' + ? { + achievements: inProgressAchievements, + title: 'Achievements In Progress', + } + : { + achievements: availableAchievements, + title: 'Available Achievements', + }; + + const initialEmbed = createAchievementsEmbed( + initialEmbedData.achievements, + initialEmbedData.title, + targetUser, + overallProgress, + earnedCount, + totalAchievements, + ); + + // Define pagination variables + const achievementsPerPage = 5; + let currentPage = 0; + + const pages = splitAchievementsIntoPages( + initialEmbedData.achievements, + initialEmbedData.title, + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + + // Create achievements type selector + const selectMenu = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('achievement_view') + .setPlaceholder('Select achievement type') + .addOptions( + options.map((opt) => + new StringSelectMenuOptionBuilder() + .setLabel(`${opt.label} (${opt.count})`) + .setValue(opt.value) + .setDefault(opt.value === initialOption), + ), + ), + ); + + // Create pagination buttons + const paginationRow = createPaginationButtons(pages.length, currentPage); + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components: [selectMenu, ...(pages.length > 1 ? [paginationRow] : [])], + }); + + if (options.length <= 1 && pages.length <= 1) return; + + // Create collector for both select menu and button interactions + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 60000, + }); + + const buttonCollector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: 60000, + }); + + collector.on('collect', async (i: StringSelectMenuInteraction) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use this menu.', + ephemeral: true, + }); + return; + } + + await i.deferUpdate(); + + const selected = i.values[0]; + let categoryPages; + let selectedAchievements; + + if (selected === 'earned') { + selectedAchievements = earnedAchievements; + categoryPages = splitAchievementsIntoPages( + earnedAchievements, + 'Earned Achievements', + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + } else if (selected === 'progress') { + selectedAchievements = inProgressAchievements; + categoryPages = splitAchievementsIntoPages( + inProgressAchievements, + 'Achievements In Progress', + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + } else if (selected === 'available') { + selectedAchievements = availableAchievements; + categoryPages = splitAchievementsIntoPages( + availableAchievements, + 'Available Achievements', + targetUser, + overallProgress, + earnedCount, + totalAchievements, + achievementsPerPage, + ); + } + + if (categoryPages && categoryPages.length > 0) { + currentPage = 0; + pages.splice(0, pages.length, ...categoryPages); + + const updatedSelectMenu = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('achievement_view') + .setPlaceholder('Select achievement type') + .addOptions( + options.map((opt) => + new StringSelectMenuOptionBuilder() + .setLabel(`${opt.label} (${opt.count})`) + .setValue(opt.value) + .setDefault(opt.value === selected), + ), + ), + ); + + const updatedPaginationRow = createPaginationButtons( + pages.length, + currentPage, + ); + + await i.editReply({ + embeds: [pages[currentPage]], + components: [ + updatedSelectMenu, + ...(pages.length > 1 ? [updatedPaginationRow] : []), + ], + }); + } + }); + + buttonCollector.on('collect', async (i: ButtonInteraction) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use these buttons.', + ephemeral: true, + }); + return; + } + + await i.deferUpdate(); + + if (i.customId === 'first') { + currentPage = 0; + } else if (i.customId === 'prev') { + currentPage = Math.max(0, currentPage - 1); + } else if (i.customId === 'next') { + currentPage = Math.min(pages.length - 1, currentPage + 1); + } else if (i.customId === 'last') { + currentPage = pages.length - 1; + } + + const updatedPaginationRow = createPaginationButtons( + pages.length, + currentPage, + ); + + await i.editReply({ + embeds: [pages[currentPage]], + components: [selectMenu, updatedPaginationRow], + }); + }); + + collector.on('end', () => { + buttonCollector.stop(); + }); + + buttonCollector.on('end', () => { + interaction.editReply({ components: [] }).catch((err) => { + console.error('Failed to edit reply after collector ended.', err); + }); + }); + } catch (error) { + console.error('Error viewing user achievements:', error); + await interaction.editReply( + 'An error occurred while fetching user achievements.', + ); + } +} + +/** + * Handle removing an achievement from a user + */ +async function handleUnawardAchievement( + interaction: ChatInputCommandInteraction, +) { + const user = interaction.options.getUser('user')!; + const achievementId = interaction.options.getInteger('achievement_id')!; + + try { + const allAchievements = await getAllAchievements(); + const achievement = allAchievements.find((a) => a.id === achievementId); + + if (!achievement) { + await interaction.editReply( + `Achievement with ID ${achievementId} not found.`, + ); + return; + } + + const userAchievements = await getUserAchievements(user.id); + const earnedAchievement = userAchievements.find( + (ua) => ua.achievementId === achievementId && ua.earnedAt !== null, + ); + + if (!earnedAchievement) { + await interaction.editReply( + `${user.username} has not earned the achievement "${achievement.name}".`, + ); + return; + } + + const success = await removeUserAchievement(user.id, achievementId); + + if (success) { + await interaction.editReply( + `Achievement "${achievement.name}" has been removed from ${user.username}.`, + ); + + if (achievement.rewardType === 'role' && achievement.rewardValue) { + try { + const member = await interaction.guild!.members.fetch(user.id); + await member.roles.remove(achievement.rewardValue); + } catch (err) { + console.error( + `Failed to remove role ${achievement.rewardValue} from user ${user.id}`, + err, + ); + await interaction.followUp({ + content: + 'Note: Failed to remove the role reward. Please check permissions and remove it manually if needed.', + ephemeral: true, + }); + } + } + } else { + await interaction.editReply( + `Failed to remove achievement "${achievement.name}" from ${user.username}.`, + ); + } + } catch (error) { + console.error('Error removing achievement from user:', error); + await interaction.editReply( + 'An error occurred while removing the achievement.', + ); + } +} + +function createAchievementsEmbed( + achievements: Array, + title: string, + user: any, + overallProgress: number = 0, + earnedCount: number = 0, + totalAchievements: number = 0, +) { + return createPageEmbed( + achievements, + title, + user, + overallProgress, + earnedCount, + totalAchievements, + 1, + 1, + ); +} + +/** + * Creates a visual progress bar + * @param progress - Number between 0-100 + * @returns A string representing a progress bar + */ +function createProgressBar(progress: number): string { + const filledBars = Math.round(progress / 10); + const emptyBars = 10 - filledBars; + + const filled = '█'.repeat(filledBars); + const empty = '░'.repeat(emptyBars); + + return `[${filled}${empty}]`; +} + +function formatType(type: string): string { + return type.charAt(0).toUpperCase() + type.slice(1).replace('_', ' '); +} + +/** + * Splits achievements into pages for pagination + */ +function splitAchievementsIntoPages( + achievements: Array, + title: string, + user: any, + overallProgress: number = 0, + earnedCount: number = 0, + totalAchievements: number = 0, + achievementsPerPage: number = 5, +): EmbedBuilder[] { + if (achievements.length === 0) { + return [ + createAchievementsEmbed( + achievements, + title, + user, + overallProgress, + earnedCount, + totalAchievements, + ), + ]; + } + + const groupedAchievements: Record = { + message_count: achievements.filter( + (a) => a.definition?.requirementType === 'message_count', + ), + level: achievements.filter( + (a) => a.definition?.requirementType === 'level', + ), + command_usage: achievements.filter( + (a) => a.definition?.requirementType === 'command_usage', + ), + reactions: achievements.filter( + (a) => a.definition?.requirementType === 'reactions', + ), + other: achievements.filter( + (a) => + !['message_count', 'level', 'command_usage', 'reactions'].includes( + a.definition?.requirementType, + ), + ), + }; + + let orderedAchievements: typeof achievements = []; + for (const [type, typeAchievements] of Object.entries(groupedAchievements)) { + if (typeAchievements.length > 0) { + orderedAchievements = orderedAchievements.concat( + typeAchievements.map((ach) => ({ + ...ach, + achievementType: type, + })), + ); + } + } + + const chunks: (typeof achievements)[] = []; + for (let i = 0; i < orderedAchievements.length; i += achievementsPerPage) { + chunks.push(orderedAchievements.slice(i, i + achievementsPerPage)); + } + + return chunks.map((chunk, index) => { + return createPageEmbed( + chunk, + title, + user, + overallProgress, + earnedCount, + totalAchievements, + index + 1, + chunks.length, + ); + }); +} + +/** + * Creates an embed for a single page of achievements + */ +function createPageEmbed( + achievements: Array, + title: string, + user: any, + overallProgress: number = 0, + earnedCount: number = 0, + totalAchievements: number = 0, + pageNumber: number = 1, + totalPages: number = 1, +): EmbedBuilder { + const embed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle(`${user.username}'s ${title}`) + .setThumbnail(user.displayAvatarURL()) + .setFooter({ text: `Page ${pageNumber}/${totalPages}` }); + + if (achievements.length === 0) { + embed.setDescription('No achievements found.'); + return embed; + } + + let currentType: string | null = null; + + achievements.forEach((achievement) => { + const { definition, achievementType } = achievement; + if (!definition) return; + + if (achievementType && achievementType !== currentType) { + currentType = achievementType; + embed.addFields({ + name: `${formatType(currentType || '')} Achievements`, + value: '⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯', + }); + } + + let fieldValue = definition.description; + + if ( + achievement.earnedAt && + achievement.earnedAt !== null && + achievement.earnedAt !== undefined && + new Date(achievement.earnedAt).getTime() > 0 + ) { + const earnedDate = new Date(achievement.earnedAt); + fieldValue += `\n✅ **Completed**: `; + } else { + const progress = achievement.progress || 0; + const progressBar = createProgressBar(progress); + fieldValue += `\n${progressBar} **${progress}%**`; + + if (definition.requirementType === 'message_count') { + fieldValue += `\n📨 Send ${definition.threshold} messages`; + } else if (definition.requirementType === 'level') { + fieldValue += `\n🏆 Reach level ${definition.threshold}`; + } else if (definition.requirementType === 'command_usage') { + const cmdName = definition.requirement?.command || 'unknown'; + fieldValue += `\n🔧 Use /${cmdName} command`; + } else if (definition.requirementType === 'reactions') { + fieldValue += `\n😀 Add ${definition.threshold} reactions`; + } + } + + if (definition.rewardType && definition.rewardValue) { + fieldValue += `\n💰 **Reward**: ${ + definition.rewardType === 'xp' + ? `${definition.rewardValue} XP` + : `Role <@&${definition.rewardValue}>` + }`; + } + + embed.addFields({ + name: definition.name, + value: fieldValue, + }); + }); + + embed.addFields({ + name: '📊 Overall Achievement Progress', + value: + `${createProgressBar(overallProgress)} **${overallProgress}%**\n` + + `You've earned **${earnedCount}** of ${totalAchievements} achievements`, + }); + + return embed; +} + +export default command; diff --git a/src/commands/fun/counting.ts b/src/commands/fun/counting.ts new file mode 100644 index 0000000..d576637 --- /dev/null +++ b/src/commands/fun/counting.ts @@ -0,0 +1,125 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + PermissionsBitField, +} from 'discord.js'; + +import { SubcommandCommand } from '@/types/CommandTypes.js'; +import { getCountingData, setCount } from '@/util/countingManager.js'; +import { loadConfig } from '@/util/configLoader.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('counting') + .setDescription('Commands related to the counting channel') + .addSubcommand((subcommand) => + subcommand + .setName('status') + .setDescription('Check the current counting status'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('setcount') + .setDescription( + 'Set the current count to a specific number (Admin only)', + ) + .addIntegerOption((option) => + option + .setName('count') + .setDescription('The number to set as the current count') + .setRequired(true) + .setMinValue(0), + ), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply(); + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'status') { + const countingData = await getCountingData(); + const countingChannelId = loadConfig().channels.counting; + + const embed = new EmbedBuilder() + .setTitle('Counting Channel Status') + .setColor(0x0099ff) + .addFields( + { + name: 'Current Count', + value: countingData.currentCount.toString(), + inline: true, + }, + { + name: 'Next Number', + value: (countingData.currentCount + 1).toString(), + inline: true, + }, + { + name: 'Highest Count', + value: countingData.highestCount.toString(), + inline: true, + }, + { + name: 'Total Correct Counts', + value: countingData.totalCorrect.toString(), + inline: true, + }, + { + name: 'Counting Channel', + value: `<#${countingChannelId}>`, + inline: true, + }, + ) + .setFooter({ text: 'Remember: No user can count twice in a row!' }) + .setTimestamp(); + + if (countingData.lastUserId) { + embed.addFields({ + name: 'Last Counter', + value: `<@${countingData.lastUserId}>`, + inline: true, + }); + } + + await interaction.editReply({ embeds: [embed] }); + } else if (subcommand === 'setcount') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.editReply({ + content: 'You need administrator permissions to use this command.', + }); + return; + } + + const count = interaction.options.getInteger('count'); + if (count === null) { + await interaction.editReply({ + content: 'Invalid count specified.', + }); + return; + } + + try { + await setCount(count); + await interaction.editReply({ + content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`, + }); + } catch (error) { + await interaction.editReply({ + content: `Failed to set the count: ${error}`, + }); + } + + await interaction.editReply({ + content: `Count has been set to **${count}**. The next number should be **${count + 1}**.`, + }); + } + }, +}; + +export default command; diff --git a/src/commands/fun/fact.ts b/src/commands/fun/fact.ts new file mode 100644 index 0000000..109bdd3 --- /dev/null +++ b/src/commands/fun/fact.ts @@ -0,0 +1,311 @@ +import { + SlashCommandBuilder, + PermissionsBitField, + EmbedBuilder, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, +} from 'discord.js'; + +import { + addFact, + getPendingFacts, + approveFact, + deleteFact, + getLastInsertedFactId, +} from '@/db/db.js'; +import { postFactOfTheDay } from '@/util/factManager.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { SubcommandCommand } from '@/types/CommandTypes.js'; +import { createPaginationButtons } from '@/util/helpers.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('fact') + .setDescription('Manage facts of the day') + .addSubcommand((subcommand) => + subcommand + .setName('submit') + .setDescription('Submit a new fact for approval') + .addStringOption((option) => + option + .setName('content') + .setDescription('The fact content') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('source') + .setDescription('Source of the fact (optional)') + .setRequired(false), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('approve') + .setDescription('Approve a pending fact (Mod only)') + .addIntegerOption((option) => + option + .setName('id') + .setDescription('The ID of the fact to approve') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('delete') + .setDescription('Delete a fact (Mod only)') + .addIntegerOption((option) => + option + .setName('id') + .setDescription('The ID of the fact to delete') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('pending') + .setDescription('List all pending facts (Mod only)'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('post') + .setDescription('Post a fact of the day manually (Admin only)'), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ + flags: ['Ephemeral'], + }); + + const config = loadConfig(); + const subcommand = interaction.options.getSubcommand(); + + if (subcommand === 'submit') { + const content = interaction.options.getString('content', true); + const source = interaction.options.getString('source') || undefined; + + const isAdmin = interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ); + + await addFact({ + content, + source, + addedBy: interaction.user.id, + approved: isAdmin ? true : false, + }); + + if (!isAdmin) { + const approvalChannel = interaction.guild.channels.cache.get( + config.channels.factApproval, + ); + + if (approvalChannel?.isTextBased()) { + const embed = new EmbedBuilder() + .setTitle('New Fact Submission') + .setDescription(content) + .setColor(0x0099ff) + .addFields( + { + name: 'Submitted By', + value: `<@${interaction.user.id}>`, + inline: true, + }, + { name: 'Source', value: source || 'Not provided', inline: true }, + ) + .setTimestamp(); + + const factId = await getLastInsertedFactId(); + + const approveButton = new ButtonBuilder() + .setCustomId(`approve_fact_${factId}`) + .setLabel('Approve') + .setStyle(ButtonStyle.Success); + + const rejectButton = new ButtonBuilder() + .setCustomId(`reject_fact_${factId}`) + .setLabel('Reject') + .setStyle(ButtonStyle.Danger); + + const row = new ActionRowBuilder().addComponents( + approveButton, + rejectButton, + ); + + await approvalChannel.send({ + embeds: [embed], + components: [row], + }); + } else { + console.error('Approval channel not found or is not a text channel'); + } + } + + await interaction.editReply({ + content: isAdmin + ? 'Your fact has been automatically approved and added to the database!' + : 'Your fact has been submitted for approval!', + }); + } else if (subcommand === 'approve') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to approve facts.', + }); + return; + } + + const id = interaction.options.getInteger('id', true); + await approveFact(id); + + await interaction.editReply({ + content: `Fact #${id} has been approved!`, + }); + } else if (subcommand === 'delete') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to delete facts.', + }); + return; + } + + const id = interaction.options.getInteger('id', true); + await deleteFact(id); + + await interaction.editReply({ + content: `Fact #${id} has been deleted!`, + }); + } else if (subcommand === 'pending') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to view pending facts.', + }); + return; + } + + const FACTS_PER_PAGE = 5; + const pendingFacts = await getPendingFacts(); + + if (pendingFacts.length === 0) { + await interaction.editReply({ + content: 'There are no pending facts.', + }); + return; + } + + const pages: EmbedBuilder[] = []; + for (let i = 0; i < pendingFacts.length; i += FACTS_PER_PAGE) { + const pageFacts = pendingFacts.slice(i, i + FACTS_PER_PAGE); + + const embed = new EmbedBuilder() + .setTitle('Pending Facts') + .setColor(0x0099ff) + .setDescription( + pageFacts + .map((fact) => { + return `**ID #${fact.id}**\n${fact.content}\nSubmitted by: <@${fact.addedBy}>\nSource: ${fact.source || 'Not provided'}`; + }) + .join('\n\n'), + ) + .setTimestamp(); + + pages.push(embed); + } + + let currentPage = 0; + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + + if (pages.length <= 1) return; + + const collector = message.createMessageComponentCollector({ + time: 300000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'These controls are not for you!', + flags: ['Ephemeral'], + }); + return; + } + + if (i.isButton()) { + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + } + + if (i.isStringSelectMenu()) { + const selected = parseInt(i.values[0]); + if (!isNaN(selected) && selected >= 0 && selected < pages.length) { + currentPage = selected; + } + } + + await i.update({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + }); + + collector.on('end', async () => { + if (message) { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + console.error('Error removing components:', error); + } + } + }); + } else if (subcommand === 'post') { + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to manually post facts.', + }); + return; + } + + await postFactOfTheDay(interaction.client); + + await interaction.editReply({ + content: 'Fact of the day has been posted!', + }); + } + }, +}; + +export default command; diff --git a/src/commands/fun/giveaway.ts b/src/commands/fun/giveaway.ts new file mode 100644 index 0000000..e437c3c --- /dev/null +++ b/src/commands/fun/giveaway.ts @@ -0,0 +1,376 @@ +import { + SlashCommandBuilder, + PermissionsBitField, + EmbedBuilder, + ChatInputCommandInteraction, +} from 'discord.js'; + +import { SubcommandCommand } from '@/types/CommandTypes.js'; +import { + getGiveaway, + getActiveGiveaways, + endGiveaway, + rerollGiveaway, +} from '@/db/db.js'; +import { + createGiveawayEmbed, + formatWinnerMentions, + builder, +} from '@/util/giveaways/giveawayManager.js'; +import { createPaginationButtons } from '@/util/helpers.js'; +import { loadConfig } from '@/util/configLoader.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('giveaway') + .setDescription('Create and manage giveaways') + .addSubcommand((sub) => + sub.setName('create').setDescription('Start creating a new giveaway'), + ) + .addSubcommand((sub) => + sub.setName('list').setDescription('List all active giveaways'), + ) + .addSubcommand((sub) => + sub + .setName('end') + .setDescription('End a giveaway early') + .addStringOption((opt) => + opt + .setName('id') + .setDescription('Id of the giveaway') + .setRequired(true), + ), + ) + .addSubcommand((sub) => + sub + .setName('reroll') + .setDescription('Reroll winners for a giveaway') + .addStringOption((opt) => + opt + .setName('id') + .setDescription('Id of the giveaway') + .setRequired(true), + ), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + const config = loadConfig(); + const communityManagerRoleId = config.roles.staffRoles.find( + (role) => role.name === 'Community Manager', + )?.roleId; + + if (!communityManagerRoleId) { + await interaction.reply({ + content: + 'Community Manager role not found in the configuration. Please contact a server admin.', + flags: ['Ephemeral'], + }); + return; + } + + if ( + !interaction.guild.members.cache + .find((member) => member.id === interaction.user.id) + ?.roles.cache.has(communityManagerRoleId) + ) { + await interaction.reply({ + content: 'You do not have permission to manage giveaways.', + flags: ['Ephemeral'], + }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + + switch (subcommand) { + case 'create': + await handleCreateGiveaway(interaction); + break; + case 'list': + await handleListGiveaways(interaction); + break; + case 'end': + await handleEndGiveaway(interaction); + break; + case 'reroll': + await handleRerollGiveaway(interaction); + break; + } + }, +}; + +/** + * Initialize the giveaway creation process + */ +async function handleCreateGiveaway(interaction: ChatInputCommandInteraction) { + await builder.startGiveawayBuilder(interaction); +} + +/** + * Handle the list giveaways subcommand + */ +async function handleListGiveaways(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + const GIVEAWAYS_PER_PAGE = 5; + + try { + const activeGiveaways = await getActiveGiveaways(); + + if (activeGiveaways.length === 0) { + await interaction.editReply({ + content: 'There are no active giveaways at the moment.', + }); + return; + } + + const pages: EmbedBuilder[] = []; + for (let i = 0; i < activeGiveaways.length; i += GIVEAWAYS_PER_PAGE) { + const pageGiveaways = activeGiveaways.slice(i, i + GIVEAWAYS_PER_PAGE); + + const embed = new EmbedBuilder() + .setTitle('🎉 Active Giveaways') + .setColor(0x00ff00) + .setDescription('Here are the currently active giveaways:') + .setTimestamp(); + + pageGiveaways.forEach((giveaway) => { + embed.addFields({ + name: `${giveaway.prize} (ID: ${giveaway.id})`, + value: [ + `**Hosted by:** <@${giveaway.hostId}>`, + `**Winners:** ${giveaway.winnerCount}`, + `**Ends:** `, + `**Entries:** ${giveaway.participants?.length || 0}`, + `[Jump to Giveaway](https://discord.com/channels/${interaction.guildId}/${giveaway.channelId}/${giveaway.messageId})`, + ].join('\n'), + inline: false, + }); + }); + + pages.push(embed); + } + + let currentPage = 0; + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + + const collector = message.createMessageComponentCollector({ + time: 300000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use these buttons.', + flags: ['Ephemeral'], + }); + return; + } + + if (i.isButton()) { + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + + await i.update({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + } + }); + + collector.on('end', async () => { + try { + await interaction.editReply({ + components: [], + }); + } catch (error) { + console.error('Error removing components:', error); + } + }); + } catch (error) { + console.error('Error fetching giveaways:', error); + await interaction.editReply({ + content: 'There was an error fetching the giveaways.', + }); + } +} + +/** + * Handle the end giveaway subcommand + */ +async function handleEndGiveaway(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + + const id = interaction.options.getString('id', true); + const giveaway = await getGiveaway(id, true); + + if (!giveaway) { + await interaction.editReply(`Giveaway with ID ${id} not found.`); + return; + } + + if (giveaway.status !== 'active') { + await interaction.editReply('This giveaway has already ended.'); + return; + } + + const endedGiveaway = await endGiveaway(id, true); + if (!endedGiveaway) { + await interaction.editReply( + 'Failed to end the giveaway. Please try again.', + ); + return; + } + + try { + const channel = interaction.guild?.channels.cache.get(giveaway.channelId); + if (!channel?.isTextBased()) { + await interaction.editReply( + 'Giveaway channel not found or is not a text channel.', + ); + return; + } + + const messageId = giveaway.messageId; + const giveawayMessage = await channel.messages.fetch(messageId); + + if (!giveawayMessage) { + await interaction.editReply('Giveaway message not found.'); + return; + } + + await giveawayMessage.edit({ + embeds: [ + createGiveawayEmbed({ + id: endedGiveaway.id, + prize: endedGiveaway.prize, + hostId: endedGiveaway.hostId, + winnersIds: endedGiveaway.winnersIds ?? [], + isEnded: true, + footerText: 'Ended early by a moderator', + }), + ], + components: [], + }); + + if (endedGiveaway.winnersIds?.length) { + const winnerMentions = formatWinnerMentions(endedGiveaway.winnersIds); + await channel.send({ + content: `Congratulations ${winnerMentions}! You won **${endedGiveaway.prize}**!`, + allowedMentions: { users: endedGiveaway.winnersIds }, + }); + } else { + await channel.send( + `No one entered the giveaway for **${endedGiveaway.prize}**!`, + ); + } + + await interaction.editReply('Giveaway ended successfully!'); + } catch (error) { + console.error('Error ending giveaway:', error); + await interaction.editReply('Failed to update the giveaway message.'); + } +} + +/** + * Handle the reroll giveaway subcommand + */ +async function handleRerollGiveaway(interaction: ChatInputCommandInteraction) { + await interaction.deferReply({ flags: ['Ephemeral'] }); + const id = interaction.options.getString('id', true); + + const originalGiveaway = await getGiveaway(id, true); + + if (!originalGiveaway) { + await interaction.editReply(`Giveaway with ID ${id} not found.`); + return; + } + + if (originalGiveaway.status !== 'ended') { + await interaction.editReply( + 'This giveaway is not yet ended. You can only reroll ended giveaways.', + ); + return; + } + + if (!originalGiveaway.participants?.length) { + await interaction.editReply( + 'Cannot reroll because no one entered this giveaway.', + ); + return; + } + + const rerolledGiveaway = await rerollGiveaway(id); + + if (!rerolledGiveaway) { + await interaction.editReply( + 'Failed to reroll the giveaway. An internal error occurred.', + ); + return; + } + + const previousWinners = originalGiveaway.winnersIds ?? []; + const newWinners = rerolledGiveaway.winnersIds ?? []; + + const winnersChanged = !( + previousWinners.length === newWinners.length && + previousWinners.every((w) => newWinners.includes(w)) + ); + + if (!winnersChanged && newWinners.length > 0) { + await interaction.editReply( + 'Could not reroll: No other eligible participants found besides the previous winner(s).', + ); + return; + } + if (newWinners.length === 0) { + await interaction.editReply( + 'Could not reroll: No eligible participants found.', + ); + return; + } + + try { + const channel = interaction.guild?.channels.cache.get( + rerolledGiveaway.channelId, + ); + if (!channel?.isTextBased()) { + await interaction.editReply( + 'Giveaway channel not found or is not a text channel. Reroll successful but announcement failed.', + ); + return; + } + + const winnerMentions = formatWinnerMentions(newWinners); + await channel.send({ + content: `🎉 The giveaway for **${rerolledGiveaway.prize}** has been rerolled! New winner(s): ${winnerMentions}`, + allowedMentions: { users: newWinners }, + }); + + await interaction.editReply('Giveaway rerolled successfully!'); + } catch (error) { + console.error('Error announcing rerolled giveaway:', error); + await interaction.editReply( + 'Giveaway rerolled, but failed to announce the new winners.', + ); + } +} + +export default command; diff --git a/src/commands/fun/leaderboard.ts b/src/commands/fun/leaderboard.ts new file mode 100644 index 0000000..e2d39f3 --- /dev/null +++ b/src/commands/fun/leaderboard.ts @@ -0,0 +1,167 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + APIEmbed, + JSONEncodable, +} from 'discord.js'; + +import { OptionsCommand } from '@/types/CommandTypes.js'; +import { getLevelLeaderboard } from '@/db/db.js'; +import { createPaginationButtons } from '@/util/helpers.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('leaderboard') + .setDescription('Shows the server XP leaderboard') + .addIntegerOption((option) => + option + .setName('limit') + .setDescription('Number of users per page (default: 10)') + .setRequired(false), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply(); + + try { + const usersPerPage = interaction.options.getInteger('limit') || 10; + + const allUsers = await getLevelLeaderboard(100); + + if (allUsers.length === 0) { + const embed = new EmbedBuilder() + .setTitle('🏆 Server Leaderboard') + .setColor(0x5865f2) + .setDescription('No users found on the leaderboard yet.') + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + } + + const pages: (APIEmbed | JSONEncodable)[] = []; + + for (let i = 0; i < allUsers.length; i += usersPerPage) { + const pageUsers = allUsers.slice(i, i + usersPerPage); + let leaderboardText = ''; + + for (let j = 0; j < pageUsers.length; j++) { + const user = pageUsers[j]; + const position = i + j + 1; + + try { + const member = await interaction.guild.members.fetch( + user.discordId, + ); + leaderboardText += `**${position}.** ${member} - Level ${user.level} (${user.xp} XP)\n`; + } catch (error) { + leaderboardText += `**${position}.** <@${user.discordId}> - Level ${user.level} (${user.xp} XP)\n`; + } + } + + const embed = new EmbedBuilder() + .setTitle('🏆 Server Leaderboard') + .setColor(0x5865f2) + .setDescription(leaderboardText) + .setTimestamp() + .setFooter({ + text: `Page ${Math.floor(i / usersPerPage) + 1} of ${Math.ceil(allUsers.length / usersPerPage)}`, + }); + + pages.push(embed); + } + + let currentPage = 0; + + const getButtonActionRow = () => + createPaginationButtons(pages.length, currentPage); + + const getSelectMenuRow = () => { + const options = pages.map((_, index) => ({ + label: `Page ${index + 1}`, + value: index.toString(), + default: index === currentPage, + })); + + const select = new StringSelectMenuBuilder() + .setCustomId('select_page') + .setPlaceholder('Jump to a page') + .addOptions(options); + + return new ActionRowBuilder().addComponents( + select, + ); + }; + + const components = + pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : []; + + const message = await interaction.editReply({ + embeds: [pages[currentPage]], + components, + }); + + if (pages.length <= 1) return; + + const collector = message.createMessageComponentCollector({ + time: 300000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'These controls are not for you!', + flags: ['Ephemeral'], + }); + return; + } + + if (i.isButton()) { + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + } + + if (i.isStringSelectMenu()) { + const selected = parseInt(i.values[0]); + if (!isNaN(selected) && selected >= 0 && selected < pages.length) { + currentPage = selected; + } + } + + await i.update({ + embeds: [pages[currentPage]], + components: [getButtonActionRow(), getSelectMenuRow()], + }); + }); + + collector.on('end', async () => { + if (message) { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + console.error('Error removing components:', error); + } + } + }); + } catch (error) { + console.error('Error getting leaderboard:', error); + await interaction.editReply('Failed to get leaderboard information.'); + } + }, +}; + +export default command; diff --git a/src/commands/fun/rank.ts b/src/commands/fun/rank.ts new file mode 100644 index 0000000..a9908ce --- /dev/null +++ b/src/commands/fun/rank.ts @@ -0,0 +1,44 @@ +import { SlashCommandBuilder } from 'discord.js'; + +import { OptionsCommand } from '@/types/CommandTypes.js'; +import { generateRankCard, getXpToNextLevel } from '@/util/levelingSystem.js'; +import { getUserLevel } from '@/db/db.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('rank') + .setDescription('Shows your current rank and level') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to check rank for (defaults to yourself)') + .setRequired(false), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply(); + + try { + const member = await interaction.guild.members.fetch( + (interaction.options.get('user')?.value as string) || + interaction.user.id, + ); + + const userData = await getUserLevel(member.id); + const rankCard = await generateRankCard(member, userData); + + const xpToNextLevel = getXpToNextLevel(userData.level, userData.xp); + + await interaction.editReply({ + content: `${member}'s rank - Level ${userData.level} (${userData.xp} XP, ${xpToNextLevel} XP until next level)`, + files: [rankCard], + }); + } catch (error) { + console.error('Error getting rank:', error); + await interaction.editReply('Failed to get rank information.'); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/ban.ts b/src/commands/moderation/ban.ts index 4cbc40f..d2dfccd 100644 --- a/src/commands/moderation/ban.ts +++ b/src/commands/moderation/ban.ts @@ -1,9 +1,10 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -import { updateMember, updateMemberModerationHistory } from '../../db/db.js'; -import { parseDuration, scheduleUnban } from '../../util/helpers.js'; -import { OptionsCommand } from '../../types/CommandTypes.js'; -import logAction from '../../util/logging/logAction.js'; +import { updateMember, updateMemberModerationHistory } from '@/db/db.js'; +import { parseDuration, scheduleUnban } from '@/util/helpers.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; +import { loadConfig } from '@/util/configLoader.js'; +import logAction from '@/util/logging/logAction.js'; const command: OptionsCommand = { data: new SlashCommandBuilder() @@ -30,40 +31,63 @@ const command: OptionsCommand = { .setRequired(false), ), execute: async (interaction) => { - const moderator = await interaction.guild?.members.fetch( - interaction.user.id, - ); - const member = await interaction.guild?.members.fetch( - interaction.options.get('member')!.value as string, - ); - const reason = interaction.options.get('reason')?.value as string; - const banDuration = interaction.options.get('duration')?.value as - | string - | undefined; + if (!interaction.isChatInputCommand() || !interaction.guild) return; - if ( - !interaction.memberPermissions?.has( - PermissionsBitField.Flags.BanMembers, - ) || - moderator!.roles.highest.position <= member!.roles.highest.position || - !member?.bannable - ) { - await interaction.reply({ - content: - 'You do not have permission to ban members or this member cannot be banned.', - flags: ['Ephemeral'], - }); - return; - } + await interaction.deferReply({ flags: ['Ephemeral'] }); try { - await member.user.send( - banDuration - ? `You have been banned from ${interaction.guild!.name} for ${banDuration}. Reason: ${reason}. You can join back at ${new Date( - Date.now() + parseDuration(banDuration), - ).toUTCString()} using the link below:\nhttps://discord.gg/KRTGjxx7gY` - : `You been indefinitely banned from ${interaction.guild!.name}. Reason: ${reason}.`, + const moderator = await interaction.guild.members.fetch( + interaction.user.id, ); + const member = await interaction.guild.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + const banDuration = interaction.options.get('duration')?.value as + | string + | undefined; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.BanMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to ban members.', + }); + return; + } + + if (moderator.roles.highest.position <= member.roles.highest.position) { + await interaction.editReply({ + content: + 'You cannot ban a member with equal or higher role than yours.', + }); + return; + } + + if (!member.bannable) { + await interaction.editReply({ + content: 'I do not have permission to ban this member.', + }); + return; + } + + const config = loadConfig(); + const invite = interaction.guild.vanityURLCode ?? config.serverInvite; + const until = banDuration + ? new Date(Date.now() + parseDuration(banDuration)).toUTCString() + : 'indefinitely'; + + try { + await member.user.send( + banDuration + ? `You have been banned from ${interaction.guild.name} for ${banDuration}. Reason: ${reason}. You can join back at ${until} using the link below:\n${invite}` + : `You been indefinitely banned from ${interaction.guild.name}. Reason: ${reason}.`, + ); + } catch (error) { + console.error('Failed to send DM:', error); + } await member.ban({ reason }); if (banDuration) { @@ -72,7 +96,7 @@ const command: OptionsCommand = { await scheduleUnban( interaction.client, - interaction.guild!.id, + interaction.guild.id, member.id, expiresAt, ); @@ -94,23 +118,22 @@ const command: OptionsCommand = { }); await logAction({ - guild: interaction.guild!, + guild: interaction.guild, action: 'ban', target: member, - moderator: moderator!, + moderator, reason, }); - await interaction.reply({ + await interaction.editReply({ content: banDuration ? `<@${member.id}> has been banned for ${banDuration}. Reason: ${reason}` : `<@${member.id}> has been indefinitely banned. Reason: ${reason}`, }); } catch (error) { console.error('Ban command error:', error); - await interaction.reply({ + await interaction.editReply({ content: 'Unable to ban member.', - flags: ['Ephemeral'], }); } }, diff --git a/src/commands/moderation/kick.ts b/src/commands/moderation/kick.ts new file mode 100644 index 0000000..fce2ab7 --- /dev/null +++ b/src/commands/moderation/kick.ts @@ -0,0 +1,103 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { updateMemberModerationHistory } from '@/db/db.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; +import { loadConfig } from '@/util/configLoader.js'; +import logAction from '@/util/logging/logAction.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('kick') + .setDescription('Kick a member from the server') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to kick') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for the kick') + .setRequired(true), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + try { + const moderator = await interaction.guild.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.KickMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to kick members.', + }); + return; + } + + if (moderator!.roles.highest.position <= member.roles.highest.position) { + await interaction.editReply({ + content: + 'You cannot kick a member with equal or higher role than yours.', + }); + return; + } + + if (!member.kickable) { + await interaction.editReply({ + content: 'I do not have permission to kick this member.', + }); + return; + } + + try { + await member.user.send( + `You have been kicked from ${interaction.guild!.name}. Reason: ${reason}. You can join back at: \n${interaction.guild.vanityURLCode ?? loadConfig().serverInvite}`, + ); + } catch (error) { + console.error('Failed to send DM to kicked user:', error); + } + + await member.kick(reason); + + await updateMemberModerationHistory({ + discordId: member.id, + moderatorDiscordId: interaction.user.id, + action: 'kick', + reason, + duration: '', + createdAt: new Date(), + }); + + await logAction({ + guild: interaction.guild!, + action: 'kick', + target: member, + moderator, + reason, + }); + + await interaction.editReply({ + content: `<@${member.id}> has been kicked. Reason: ${reason}`, + }); + } catch (error) { + console.error('Kick command error:', error); + await interaction.editReply({ + content: 'Unable to kick member.', + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/mute.ts b/src/commands/moderation/mute.ts new file mode 100644 index 0000000..ef7a506 --- /dev/null +++ b/src/commands/moderation/mute.ts @@ -0,0 +1,128 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { updateMember, updateMemberModerationHistory } from '@/db/db.js'; +import { parseDuration } from '@/util/helpers.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; +import logAction from '@/util/logging/logAction.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('mute') + .setDescription('Timeout a member in the server') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to timeout') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for the timeout') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('duration') + .setDescription( + 'The duration of the timeout (ex. 5m, 1h, 1d, 1w). Max 28 days.', + ) + .setRequired(true), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + try { + const moderator = await interaction.guild.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + const muteDuration = interaction.options.get('duration')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.KickMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to mute members.', + }); + return; + } + + if (moderator.roles.highest.position <= member.roles.highest.position) { + await interaction.editReply({ + content: + 'You cannot mute a member with equal or higher role than yours.', + }); + return; + } + + if (!member.moderatable) { + await interaction.editReply({ + content: 'I do not have permission to mute this member.', + }); + return; + } + + const durationMs = parseDuration(muteDuration); + const maxTimeout = 28 * 24 * 60 * 60 * 1000; + + if (durationMs > maxTimeout) { + await interaction.editReply({ + content: 'Timeout duration cannot exceed 28 days.', + }); + return; + } + + await member.user.send( + `You have been timed out in ${interaction.guild!.name} for ${muteDuration}. Reason: ${reason}.`, + ); + + await member.timeout(durationMs, reason); + + const expiresAt = new Date(Date.now() + durationMs); + + await updateMemberModerationHistory({ + discordId: member.id, + moderatorDiscordId: interaction.user.id, + action: 'mute', + reason, + duration: muteDuration, + createdAt: new Date(), + expiresAt, + active: true, + }); + + await updateMember({ + discordId: member.id, + currentlyMuted: true, + }); + + await logAction({ + guild: interaction.guild!, + action: 'mute', + target: member, + moderator, + reason, + duration: muteDuration, + }); + + await interaction.editReply({ + content: `<@${member.id}> has been muted for ${muteDuration}. Reason: ${reason}`, + }); + } catch (error) { + console.error('Mute command error:', error); + await interaction.editReply({ + content: 'Unable to timeout member.', + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/unban.ts b/src/commands/moderation/unban.ts index 8a458f2..ea7d386 100644 --- a/src/commands/moderation/unban.ts +++ b/src/commands/moderation/unban.ts @@ -1,7 +1,7 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -import { executeUnban } from '../../util/helpers.js'; -import { OptionsCommand } from '../../types/CommandTypes.js'; +import { executeUnban } from '@/util/helpers.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; const command: OptionsCommand = { data: new SlashCommandBuilder() @@ -20,52 +20,54 @@ const command: OptionsCommand = { .setRequired(true), ), execute: async (interaction) => { - const userId = interaction.options.get('userid')!.value as string; - const reason = interaction.options.get('reason')?.value as string; + if (!interaction.isChatInputCommand() || !interaction.guild) return; - if ( - !interaction.memberPermissions?.has(PermissionsBitField.Flags.BanMembers) - ) { - await interaction.reply({ - content: 'You do not have permission to unban users.', - flags: ['Ephemeral'], - }); - return; - } + await interaction.deferReply({ flags: ['Ephemeral'] }); try { + const userId = interaction.options.get('userid')?.value as string; + const reason = interaction.options.get('reason')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.BanMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to unban users.', + }); + return; + } + try { - const ban = await interaction.guild?.bans.fetch(userId); + const ban = await interaction.guild.bans.fetch(userId); if (!ban) { - await interaction.reply({ + await interaction.editReply({ content: 'This user is not banned.', - flags: ['Ephemeral'], }); return; } } catch { - await interaction.reply({ + await interaction.editReply({ content: 'Error getting ban. Is this user banned?', - flags: ['Ephemeral'], }); return; } await executeUnban( interaction.client, - interaction.guildId!, + interaction.guild.id, userId, reason, ); - await interaction.reply({ + await interaction.editReply({ content: `<@${userId}> has been unbanned. Reason: ${reason}`, }); } catch (error) { - console.error(error); - await interaction.reply({ + console.error(`Unable to unban user: ${error}`); + await interaction.editReply({ content: 'Unable to unban user.', - flags: ['Ephemeral'], }); } }, diff --git a/src/commands/moderation/unmute.ts b/src/commands/moderation/unmute.ts new file mode 100644 index 0000000..a52069f --- /dev/null +++ b/src/commands/moderation/unmute.ts @@ -0,0 +1,66 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { executeUnmute } from '@/util/helpers.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('unmute') + .setDescription('Remove a timeout from a member') + .addUserOption((option) => + option + .setName('member') + .setDescription('The member to unmute') + .setRequired(true), + ) + .addStringOption((option) => + option + .setName('reason') + .setDescription('The reason for removing the timeout') + .setRequired(true), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + try { + const moderator = await interaction.guild.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild.members.fetch( + interaction.options.get('member')!.value as string, + ); + const reason = interaction.options.get('reason')?.value as string; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to unmute members.', + }); + return; + } + await executeUnmute( + interaction.client, + interaction.guild.id, + member.id, + reason, + moderator, + ); + + await interaction.editReply({ + content: `<@${member.id}>'s timeout has been removed. Reason: ${reason}`, + }); + } catch (error) { + console.error('Unmute command error:', error); + await interaction.editReply({ + content: 'Unable to unmute member.', + }); + } + }, +}; + +export default command; diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts index c92c7ae..ad0ad83 100644 --- a/src/commands/moderation/warn.ts +++ b/src/commands/moderation/warn.ts @@ -1,8 +1,8 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -import { updateMemberModerationHistory } from '../../db/db.js'; -import { OptionsCommand } from '../../types/CommandTypes.js'; -import logAction from '../../util/logging/logAction.js'; +import { updateMemberModerationHistory } from '@/db/db.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; +import logAction from '@/util/logging/logAction.js'; const command: OptionsCommand = { data: new SlashCommandBuilder() @@ -21,29 +21,38 @@ const command: OptionsCommand = { .setRequired(true), ), execute: async (interaction) => { - const moderator = await interaction.guild?.members.fetch( - interaction.user.id, - ); - const member = await interaction.guild?.members.fetch( - interaction.options.get('member')!.value as unknown as string, - ); - const reason = interaction.options.get('reason') - ?.value as unknown as string; + if (!interaction.isChatInputCommand() || !interaction.guild) return; - if ( - !interaction.memberPermissions?.has( - PermissionsBitField.Flags.ModerateMembers, - ) || - moderator!.roles.highest.position <= member!.roles.highest.position - ) { - await interaction.reply({ - content: 'You do not have permission to warn this member.', - flags: ['Ephemeral'], - }); - return; - } + await interaction.deferReply({ flags: ['Ephemeral'] }); try { + const moderator = await interaction.guild.members.fetch( + interaction.user.id, + ); + const member = await interaction.guild.members.fetch( + interaction.options.get('member')!.value as unknown as string, + ); + const reason = interaction.options.getString('reason')!; + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.ModerateMembers, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to warn members.', + }); + return; + } + + if (moderator.roles.highest.position <= member.roles.highest.position) { + await interaction.editReply({ + content: + 'You cannot warn a member with equal or higher role than yours.', + }); + return; + } + await updateMemberModerationHistory({ discordId: member!.user.id, moderatorDiscordId: interaction.user.id, @@ -54,9 +63,6 @@ const command: OptionsCommand = { await member!.user.send( `You have been warned in **${interaction?.guild?.name}**. Reason: **${reason}**.`, ); - await interaction.reply( - `<@${member!.user.id}> has been warned. Reason: ${reason}`, - ); await logAction({ guild: interaction.guild!, action: 'warn', @@ -64,11 +70,13 @@ const command: OptionsCommand = { moderator: moderator!, reason: reason, }); + await interaction.editReply( + `<@${member!.user.id}> has been warned. Reason: ${reason}`, + ); } catch (error) { console.error(error); - await interaction.reply({ + await interaction.editReply({ content: 'There was an error trying to warn the member.', - flags: ['Ephemeral'], }); } }, diff --git a/src/commands/testing/testJoin.ts b/src/commands/testing/testJoin.ts index b1998e0..f812ae9 100644 --- a/src/commands/testing/testJoin.ts +++ b/src/commands/testing/testJoin.ts @@ -1,6 +1,6 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -import { Command } from '../../types/CommandTypes.js'; +import { Command } from '@/types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() @@ -8,25 +8,27 @@ const command: Command = { .setDescription('Simulates a new member joining'), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; const guild = interaction.guild; + await interaction.deferReply({ flags: ['Ephemeral'] }); + if ( !interaction.memberPermissions!.has( PermissionsBitField.Flags.Administrator, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to use this command.', - flags: ['Ephemeral'], }); + return; } - const fakeMember = await guild!.members.fetch(interaction.user.id); - guild!.client.emit('guildMemberAdd', fakeMember); + const fakeMember = await guild.members.fetch(interaction.user.id); + guild.client.emit('guildMemberAdd', fakeMember); - await interaction.reply({ + await interaction.editReply({ content: 'Triggered the join event!', - flags: ['Ephemeral'], }); }, }; diff --git a/src/commands/testing/testLeave.ts b/src/commands/testing/testLeave.ts index b643b0e..fae7e89 100644 --- a/src/commands/testing/testLeave.ts +++ b/src/commands/testing/testLeave.ts @@ -1,7 +1,7 @@ import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; -import { updateMember } from '../../db/db.js'; -import { Command } from '../../types/CommandTypes.js'; +import { updateMember } from '@/db/db.js'; +import { Command } from '@/types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() @@ -9,25 +9,26 @@ const command: Command = { .setDescription('Simulates a member leaving'), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; const guild = interaction.guild; + await interaction.deferReply({ flags: ['Ephemeral'] }); + if ( !interaction.memberPermissions!.has( PermissionsBitField.Flags.Administrator, ) ) { - await interaction.reply({ + await interaction.editReply({ content: 'You do not have permission to use this command.', - flags: ['Ephemeral'], }); } - const fakeMember = await guild!.members.fetch(interaction.user.id); - guild!.client.emit('guildMemberRemove', fakeMember); + const fakeMember = await guild.members.fetch(interaction.user.id); + guild.client.emit('guildMemberRemove', fakeMember); - await interaction.reply({ + await interaction.editReply({ content: 'Triggered the leave event!', - flags: ['Ephemeral'], }); await updateMember({ diff --git a/src/commands/util/config.ts b/src/commands/util/config.ts new file mode 100644 index 0000000..46d6a98 --- /dev/null +++ b/src/commands/util/config.ts @@ -0,0 +1,237 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + PermissionFlagsBits, +} from 'discord.js'; + +import { Command } from '@/types/CommandTypes.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { createPaginationButtons } from '@/util/helpers.js'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('config') + .setDescription('(Admin Only) Display the current configuration') + .setDefaultMemberPermissions(PermissionFlagsBits.Administrator), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + if ( + !interaction.memberPermissions?.has(PermissionFlagsBits.Administrator) + ) { + await interaction.editReply({ + content: 'You do not have permission to use this command.', + }); + return; + } + + const config = loadConfig(); + const displayConfig = JSON.parse(JSON.stringify(config)); + + if (displayConfig.token) displayConfig.token = '••••••••••••••••••••••••••'; + if (displayConfig.database?.dbConnectionString) { + displayConfig.database.dbConnectionString = '••••••••••••••••••••••••••'; + } + if (displayConfig.redis?.redisConnectionString) { + displayConfig.redis.redisConnectionString = '••••••••••••••••••••••••••'; + } + + const pages: EmbedBuilder[] = []; + + const basicConfigEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Bot Configuration') + .setDescription( + 'Current configuration settings (sensitive data redacted)', + ) + .addFields( + { + name: 'Client ID', + value: displayConfig.clientId || 'Not set', + inline: true, + }, + { + name: 'Guild ID', + value: displayConfig.guildId || 'Not set', + inline: true, + }, + { + name: 'Token', + value: displayConfig.token || 'Not set', + inline: true, + }, + ); + + pages.push(basicConfigEmbed); + + if (displayConfig.database || displayConfig.redis) { + const dbRedisEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Database and Redis Configuration') + .setDescription('Database and cache settings'); + + if (displayConfig.database) { + dbRedisEmbed.addFields({ + name: 'Database', + value: `Connection: ${displayConfig.database.dbConnectionString}\nMax Retry: ${displayConfig.database.maxRetryAttempts}\nRetry Delay: ${displayConfig.database.retryDelay}ms`, + }); + } + + if (displayConfig.redis) { + dbRedisEmbed.addFields({ + name: 'Redis', + value: `Connection: ${displayConfig.redis.redisConnectionString}\nRetry Attempts: ${displayConfig.redis.retryAttempts}\nInitial Retry Delay: ${displayConfig.redis.initialRetryDelay}ms`, + }); + } + + pages.push(dbRedisEmbed); + } + + if (displayConfig.channels || displayConfig.roles) { + const channelsRolesEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Channels and Roles Configuration') + .setDescription('Server channel and role settings'); + + if (displayConfig.channels) { + const channelsText = Object.entries(displayConfig.channels) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + + channelsRolesEmbed.addFields({ + name: 'Channels', + value: channelsText || 'None configured', + }); + } + + if (displayConfig.roles) { + let rolesText = ''; + + if (displayConfig.roles.joinRoles?.length) { + rolesText += `Join Roles: ${displayConfig.roles.joinRoles.join(', ')}\n`; + } + + if (displayConfig.roles.levelRoles?.length) { + rolesText += `Level Roles: ${displayConfig.roles.levelRoles.length} configured\n`; + } + + if (displayConfig.roles.staffRoles?.length) { + rolesText += `Staff Roles: ${displayConfig.roles.staffRoles.length} configured\n`; + } + + if (displayConfig.roles.factPingRole) { + rolesText += `Fact Ping Role: ${displayConfig.roles.factPingRole}`; + } + + channelsRolesEmbed.addFields({ + name: 'Roles', + value: rolesText || 'None configured', + }); + } + + pages.push(channelsRolesEmbed); + } + + if ( + displayConfig.leveling || + displayConfig.counting || + displayConfig.giveaways + ) { + const featuresEmbed = new EmbedBuilder() + .setColor(0x0099ff) + .setTitle('Feature Configurations') + .setDescription('Settings for bot features'); + + if (displayConfig.leveling) { + featuresEmbed.addFields({ + name: 'Leveling', + value: `XP Cooldown: ${displayConfig.leveling.xpCooldown}s\nMin XP: ${displayConfig.leveling.minXpAwarded}\nMax XP: ${displayConfig.leveling.maxXpAwarded}`, + }); + } + + if (displayConfig.counting) { + const countingText = Object.entries(displayConfig.counting) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + + featuresEmbed.addFields({ + name: 'Counting', + value: countingText || 'Default settings', + }); + } + + if (displayConfig.giveaways) { + const giveawaysText = Object.entries(displayConfig.giveaways) + .map(([key, value]) => `${key}: ${value}`) + .join('\n'); + + featuresEmbed.addFields({ + name: 'Giveaways', + value: giveawaysText || 'Default settings', + }); + } + + pages.push(featuresEmbed); + } + + let currentPage = 0; + + const components = + pages.length > 1 + ? [createPaginationButtons(pages.length, currentPage)] + : []; + + const reply = await interaction.editReply({ + embeds: [pages[currentPage]], + components, + }); + + if (pages.length <= 1) return; + + const collector = reply.createMessageComponentCollector({ + time: 300000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use these buttons.', + flags: ['Ephemeral'], + }); + return; + } + + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; + } + + await i.update({ + embeds: [pages[currentPage]], + components: [createPaginationButtons(pages.length, currentPage)], + }); + }); + + collector.on('end', async () => { + try { + await interaction.editReply({ components: [] }); + } catch (error) { + console.error('Failed to remove pagination buttons:', error); + } + }); + }, +}; + +export default command; diff --git a/src/commands/util/help.ts b/src/commands/util/help.ts new file mode 100644 index 0000000..d2a253c --- /dev/null +++ b/src/commands/util/help.ts @@ -0,0 +1,273 @@ +import { + SlashCommandBuilder, + EmbedBuilder, + ActionRowBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder, + ComponentType, +} from 'discord.js'; + +import { OptionsCommand } from '@/types/CommandTypes.js'; +import { ExtendedClient } from '@/structures/ExtendedClient.js'; + +const DOC_BASE_URL = 'https://docs.poixpixel.ahmadk953.org/'; +const getDocUrl = (location: string) => + `${DOC_BASE_URL}?utm_source=discord&utm_medium=bot&utm_campaign=help_command&utm_content=${location}`; + +const command: OptionsCommand = { + data: new SlashCommandBuilder() + .setName('help') + .setDescription('Shows a list of all available commands') + .addStringOption((option) => + option + .setName('command') + .setDescription('Get detailed help for a specific command') + .setRequired(false), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply(); + + try { + const client = interaction.client as ExtendedClient; + const commandName = interaction.options.getString('command'); + + if (commandName) { + return handleSpecificCommand(interaction, client, commandName); + } + + const categories = new Map(); + + for (const [name, cmd] of client.commands) { + const category = getCategoryFromCommand(name); + + if (!categories.has(category)) { + categories.set(category, []); + } + + categories.get(category).push({ + name, + description: cmd.data.toJSON().description, + }); + } + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle('Poixpixel Bot Commands') + .setDescription( + '**Welcome to Poixpixel Discord Bot!**\n\n' + + 'Select a category from the dropdown menu below to see available commands.\n\n' + + `📚 **Documentation:** [Visit Our Documentation](${getDocUrl('main_description')})`, + ) + .setThumbnail(client.user!.displayAvatarURL()) + .setFooter({ + text: 'Use /help [command] for detailed info about a command', + }); + + const categoryEmojis: Record = { + fun: '🎮', + moderation: '🛡️', + util: '🔧', + testing: '🧪', + }; + + Array.from(categories.keys()).forEach((category) => { + const emoji = categoryEmojis[category] || '📁'; + embed.addFields({ + name: `${emoji} ${category.charAt(0).toUpperCase() + category.slice(1)}`, + value: `Use the dropdown to see ${category} commands`, + inline: true, + }); + }); + + embed.addFields({ + name: '📚 Documentation', + value: `[Click here to access our full documentation](${getDocUrl('main_footer_field')})`, + inline: false, + }); + + const selectMenu = + new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('help_category_select') + .setPlaceholder('Select a command category') + .addOptions( + Array.from(categories.keys()).map((category) => { + const emoji = categoryEmojis[category] || '📁'; + return new StringSelectMenuOptionBuilder() + .setLabel( + category.charAt(0).toUpperCase() + category.slice(1), + ) + .setDescription(`View ${category} commands`) + .setValue(category) + .setEmoji(emoji); + }), + ), + ); + + const message = await interaction.editReply({ + embeds: [embed], + components: [selectMenu], + }); + + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 60000, + }); + + collector.on('collect', async (i) => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'You cannot use this menu.', + ephemeral: true, + }); + return; + } + + const selectedCategory = i.values[0]; + const commands = categories.get(selectedCategory); + const emoji = categoryEmojis[selectedCategory] || '📁'; + + const categoryEmbed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle( + `${emoji} ${selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1)} Commands`, + ) + .setDescription('Here are all the commands in this category:') + .setFooter({ + text: 'Use /help [command] for detailed info about a command', + }); + + commands.forEach((cmd: any) => { + categoryEmbed.addFields({ + name: `/${cmd.name}`, + value: cmd.description || 'No description available', + inline: false, + }); + }); + + categoryEmbed.addFields({ + name: '📚 Documentation', + value: `[Click here to access our full documentation](${getDocUrl(`category_${selectedCategory}`)})`, + inline: false, + }); + + await i.update({ embeds: [categoryEmbed], components: [selectMenu] }); + }); + + collector.on('end', () => { + interaction.editReply({ components: [] }).catch(console.error); + }); + } catch (error) { + console.error('Error in help command:', error); + await interaction.editReply({ + content: 'An error occurred while processing your request.', + }); + } + }, +}; + +/** + * Handle showing help for a specific command + */ +async function handleSpecificCommand( + interaction: any, + client: ExtendedClient, + commandName: string, +) { + const cmd = client.commands.get(commandName); + + if (!cmd) { + return interaction.editReply({ + content: `Command \`${commandName}\` not found.`, + ephemeral: true, + }); + } + + const embed = new EmbedBuilder() + .setColor('#0099ff') + .setTitle(`Help: /${commandName}`) + .setDescription(cmd.data.toJSON().description || 'No description available') + .addFields({ + name: 'Category', + value: getCategoryFromCommand(commandName), + inline: true, + }) + .setFooter({ + text: `Poixpixel Discord Bot • Documentation: ${getDocUrl(`cmd_footer_${commandName}`)}`, + }); + + const options = cmd.data.toJSON().options; + if (options && options.length > 0) { + if (options[0].type === 1) { + embed.addFields({ + name: 'Subcommands', + value: options + .map((opt: any) => `\`${opt.name}\`: ${opt.description}`) + .join('\n'), + inline: false, + }); + } else { + embed.addFields({ + name: 'Options', + value: options + .map( + (opt: any) => + `\`${opt.name}\`: ${opt.description} ${opt.required ? '(Required)' : '(Optional)'}`, + ) + .join('\n'), + inline: false, + }); + } + } + + embed.addFields({ + name: '📚 Documentation', + value: `[Click here to access our full documentation](${getDocUrl(`cmd_field_${commandName}`)})`, + inline: false, + }); + + return interaction.editReply({ embeds: [embed] }); +} + +/** + * Get the category of a command based on its name + */ +function getCategoryFromCommand(commandName: string): string { + const commandCategories: Record = { + achievement: 'fun', + fact: 'fun', + rank: 'fun', + counting: 'fun', + giveaway: 'fun', + leaderboard: 'fun', + + ban: 'moderation', + kick: 'moderation', + mute: 'moderation', + unmute: 'moderation', + warn: 'moderation', + unban: 'moderation', + + ping: 'util', + server: 'util', + userinfo: 'util', + members: 'util', + rules: 'util', + restart: 'util', + reconnect: 'util', + xp: 'util', + recalculatelevels: 'util', + help: 'util', + config: 'util', + + testjoin: 'testing', + testleave: 'testing', + }; + + return commandCategories[commandName.toLowerCase()] || 'other'; +} + +export default command; diff --git a/src/commands/util/members.ts b/src/commands/util/members.ts index 083ee64..c76ec28 100644 --- a/src/commands/util/members.ts +++ b/src/commands/util/members.ts @@ -1,25 +1,28 @@ import { SlashCommandBuilder, EmbedBuilder, - ButtonBuilder, ActionRowBuilder, - ButtonStyle, StringSelectMenuBuilder, APIEmbed, JSONEncodable, } from 'discord.js'; -import { getAllMembers } from '../../db/db.js'; -import { Command } from '../../types/CommandTypes.js'; +import { getAllMembers } from '@/db/db.js'; +import { Command } from '@/types/CommandTypes.js'; +import { createPaginationButtons } from '@/util/helpers.js'; const command: Command = { data: new SlashCommandBuilder() .setName('members') .setDescription('Lists all non-bot members of the server'), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply(); + let members = await getAllMembers(); members = members.sort((a, b) => - a.discordUsername.localeCompare(b.discordUsername), + (a.discordUsername ?? '').localeCompare(b.discordUsername ?? ''), ); const ITEMS_PER_PAGE = 15; @@ -42,18 +45,7 @@ const command: Command = { let currentPage = 0; const getButtonActionRow = () => - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('previous') - .setLabel('Previous') - .setStyle(ButtonStyle.Primary) - .setDisabled(currentPage === 0), - new ButtonBuilder() - .setCustomId('next') - .setLabel('Next') - .setStyle(ButtonStyle.Primary) - .setDisabled(currentPage === pages.length - 1), - ); + createPaginationButtons(pages.length, currentPage); const getSelectMenuRow = () => { const options = pages.map((_, index) => ({ @@ -75,7 +67,7 @@ const command: Command = { const components = pages.length > 1 ? [getButtonActionRow(), getSelectMenuRow()] : []; - await interaction.reply({ + await interaction.editReply({ embeds: [pages[currentPage]], components, }); @@ -85,7 +77,7 @@ const command: Command = { if (pages.length <= 1) return; const collector = message.createMessageComponentCollector({ - time: 60000, + time: 300000, }); collector.on('collect', async (i) => { @@ -98,10 +90,19 @@ const command: Command = { } if (i.isButton()) { - if (i.customId === 'previous' && currentPage > 0) { - currentPage--; - } else if (i.customId === 'next' && currentPage < pages.length - 1) { - currentPage++; + switch (i.customId) { + case 'first': + currentPage = 0; + break; + case 'prev': + if (currentPage > 0) currentPage--; + break; + case 'next': + if (currentPage < pages.length - 1) currentPage++; + break; + case 'last': + currentPage = pages.length - 1; + break; } } diff --git a/src/commands/util/ping.ts b/src/commands/util/ping.ts index ed64529..3bf1894 100644 --- a/src/commands/util/ping.ts +++ b/src/commands/util/ping.ts @@ -1,6 +1,6 @@ import { SlashCommandBuilder } from 'discord.js'; -import { Command } from '../../types/CommandTypes.js'; +import { Command } from '@/types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() @@ -8,7 +8,7 @@ const command: Command = { .setDescription('Check the latency from you to the bot'), execute: async (interaction) => { await interaction.reply( - `Pong! Latency: ${Date.now() - interaction.createdTimestamp}ms`, + `🏓 Pong! Latency: ${Date.now() - interaction.createdTimestamp}ms`, ); }, }; diff --git a/src/commands/util/recalculatelevels.ts b/src/commands/util/recalculatelevels.ts new file mode 100644 index 0000000..8653345 --- /dev/null +++ b/src/commands/util/recalculatelevels.ts @@ -0,0 +1,37 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; + +import { Command } from '@/types/CommandTypes.js'; +import { recalculateUserLevels } from '@/util/levelingSystem.js'; + +const command: Command = { + data: new SlashCommandBuilder() + .setName('recalculatelevels') + .setDescription('(Admin Only) Recalculate all user levels'), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + await interaction.editReply('Recalculating levels...'); + + if ( + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.editReply({ + content: 'You do not have permission to use this command.', + }); + return; + } + + try { + await recalculateUserLevels(); + await interaction.editReply('Levels recalculated successfully!'); + } catch (error) { + console.error('Error recalculating levels:', error); + await interaction.editReply('Failed to recalculate levels.'); + } + }, +}; + +export default command; diff --git a/src/commands/util/reconnect.ts b/src/commands/util/reconnect.ts new file mode 100644 index 0000000..a86d248 --- /dev/null +++ b/src/commands/util/reconnect.ts @@ -0,0 +1,199 @@ +import { + CommandInteraction, + PermissionsBitField, + SlashCommandBuilder, +} from 'discord.js'; + +import { SubcommandCommand } from '@/types/CommandTypes.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { initializeDatabaseConnection, ensureDbInitialized } from '@/db/db.js'; +import { isRedisConnected } from '@/db/redis.js'; +import { + NotificationType, + notifyManagers, +} from '@/util/notificationHandler.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('reconnect') + .setDescription('(Manager Only) Force reconnection to database or Redis') + .addSubcommand((subcommand) => + subcommand + .setName('database') + .setDescription('(Manager Only) Force reconnection to the database'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('redis') + .setDescription('(Manager Only) Force reconnection to Redis cache'), + ) + .addSubcommand((subcommand) => + subcommand + .setName('status') + .setDescription( + '(Manager Only) Check connection status of database and Redis', + ), + ), + + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + const config = loadConfig(); + const managerRoleId = config.roles.staffRoles.find( + (role) => role.name === 'Manager', + )?.roleId; + + const member = await interaction.guild?.members.fetch(interaction.user.id); + const hasManagerRole = member?.roles.cache.has(managerRoleId || ''); + + if ( + !hasManagerRole && + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.editReply({ + content: + 'You do not have permission to use this command. This command is restricted to users with the Manager role.', + }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + + try { + if (subcommand === 'database') { + await handleDatabaseReconnect(interaction); + } else if (subcommand === 'redis') { + await handleRedisReconnect(interaction); + } else if (subcommand === 'status') { + await handleStatusCheck(interaction); + } + } catch (error) { + console.error(`Error in reconnect command (${subcommand}):`, error); + await interaction.editReply({ + content: `An error occurred while processing the reconnect command: \`${error}\``, + }); + } + }, +}; + +/** + * Handle database reconnection + */ +async function handleDatabaseReconnect(interaction: CommandInteraction) { + await interaction.editReply('Attempting to reconnect to the database...'); + + try { + const success = await initializeDatabaseConnection(); + + if (success) { + await interaction.editReply( + '✅ **Database reconnection successful!** All database functions should now be operational.', + ); + + notifyManagers( + interaction.client, + NotificationType.DATABASE_CONNECTION_RESTORED, + `Database connection manually restored by ${interaction.user.tag}`, + ); + } else { + await interaction.editReply( + '❌ **Database reconnection failed.** Check the logs for more details.', + ); + } + } catch (error) { + console.error('Error reconnecting to database:', error); + await interaction.editReply( + `❌ **Database reconnection failed with error:** \`${error}\``, + ); + } +} + +/** + * Handle Redis reconnection + */ +async function handleRedisReconnect(interaction: CommandInteraction) { + await interaction.editReply('Attempting to reconnect to Redis...'); + + try { + const redisModule = await import('@/db/redis.js'); + + await redisModule.ensureRedisConnection(); + + const isConnected = redisModule.isRedisConnected(); + + if (isConnected) { + await interaction.editReply( + '✅ **Redis reconnection successful!** Cache functionality is now available.', + ); + + notifyManagers( + interaction.client, + NotificationType.REDIS_CONNECTION_RESTORED, + `Redis connection manually restored by ${interaction.user.tag}`, + ); + } else { + await interaction.editReply( + '❌ **Redis reconnection failed.** The bot will continue to function without caching capabilities.', + ); + } + } catch (error) { + console.error('Error reconnecting to Redis:', error); + await interaction.editReply( + `❌ **Redis reconnection failed with error:** \`${error}\``, + ); + } +} + +/** + * Handle status check for both services + */ +async function handleStatusCheck(interaction: any) { + await interaction.editReply('Checking connection status...'); + + try { + const dbStatus = await (async () => { + try { + await ensureDbInitialized(); + return true; + } catch { + return false; + } + })(); + + const redisStatus = isRedisConnected(); + + const statusEmbed = { + title: '🔌 Service Connection Status', + fields: [ + { + name: 'Database', + value: dbStatus ? '✅ Connected' : '❌ Disconnected', + inline: true, + }, + { + name: 'Redis Cache', + value: redisStatus + ? '✅ Connected' + : '⚠️ Disconnected (caching disabled)', + inline: true, + }, + ], + color: + dbStatus && redisStatus ? 0x00ff00 : dbStatus ? 0xffaa00 : 0xff0000, + timestamp: new Date().toISOString(), + }; + + await interaction.editReply({ content: '', embeds: [statusEmbed] }); + } catch (error) { + console.error('Error checking connection status:', error); + await interaction.editReply( + `❌ **Error checking connection status:** \`${error}\``, + ); + } +} + +export default command; diff --git a/src/commands/util/restart.ts b/src/commands/util/restart.ts new file mode 100644 index 0000000..b2bd091 --- /dev/null +++ b/src/commands/util/restart.ts @@ -0,0 +1,95 @@ +import { PermissionsBitField, SlashCommandBuilder } from 'discord.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +import { Command } from '@/types/CommandTypes.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { + NotificationType, + notifyManagers, +} from '@/util/notificationHandler.js'; +import { isRedisConnected } from '@/db/redis.js'; +import { ensureDatabaseConnection } from '@/db/db.js'; + +const execAsync = promisify(exec); + +const command: Command = { + data: new SlashCommandBuilder() + .setName('restart') + .setDescription('(Manager Only) Restart the bot'), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply({ flags: ['Ephemeral'] }); + + const config = loadConfig(); + const managerRoleId = config.roles.staffRoles.find( + (role) => role.name === 'Manager', + )?.roleId; + + const member = await interaction.guild.members.fetch(interaction.user.id); + const hasManagerRole = member?.roles.cache.has(managerRoleId || ''); + + if ( + !hasManagerRole && + !interaction.memberPermissions?.has( + PermissionsBitField.Flags.Administrator, + ) + ) { + await interaction.editReply({ + content: + 'You do not have permission to restart the bot. This command is restricted to users with the Manager role.', + }); + return; + } + + await interaction.editReply({ + content: 'Restarting the bot... This may take a few moments.', + }); + + const dbConnected = await ensureDatabaseConnection(); + const redisConnected = isRedisConnected(); + let statusInfo = ''; + + if (!dbConnected) { + statusInfo += '⚠️ Database is currently disconnected\n'; + } + + if (!redisConnected) { + statusInfo += '⚠️ Redis caching is currently unavailable\n'; + } + + if (dbConnected && redisConnected) { + statusInfo = '✅ All services are operational\n'; + } + + await notifyManagers( + interaction.client, + NotificationType.BOT_RESTARTING, + `Restart initiated by ${interaction.user.tag}\n\nCurrent service status:\n${statusInfo}`, + ); + + setTimeout(async () => { + try { + console.log( + `Bot restart initiated by ${interaction.user.tag} (${interaction.user.id})`, + ); + + await execAsync('yarn restart'); + } catch (error) { + console.error('Failed to restart the bot:', error); + try { + await interaction.followUp({ + content: + 'Failed to restart the bot. Check the console for details.', + flags: ['Ephemeral'], + }); + } catch { + // If this fails too, we can't do much + } + } + }, 1000); + }, +}; + +export default command; diff --git a/src/commands/util/rules.ts b/src/commands/util/rules.ts index c8ff029..8720c1a 100644 --- a/src/commands/util/rules.ts +++ b/src/commands/util/rules.ts @@ -1,6 +1,6 @@ import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; -import { Command } from '../../types/CommandTypes.js'; +import { Command } from '@/types/CommandTypes.js'; const rulesEmbed = new EmbedBuilder() .setColor(0x0099ff) diff --git a/src/commands/util/server.ts b/src/commands/util/server.ts index a391e03..691cca0 100644 --- a/src/commands/util/server.ts +++ b/src/commands/util/server.ts @@ -1,14 +1,16 @@ import { SlashCommandBuilder } from 'discord.js'; -import { Command } from '../../types/CommandTypes.js'; +import { Command } from '@/types/CommandTypes.js'; const command: Command = { data: new SlashCommandBuilder() .setName('server') .setDescription('Provides information about the server.'), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + await interaction.reply( - `The server **${interaction!.guild!.name}** has **${interaction!.guild!.memberCount}** members and was created on **${interaction!.guild!.createdAt}**. It is **${new Date().getFullYear() - interaction!.guild!.createdAt.getFullYear()!}** years old.`, + `The server **${interaction.guild.name}** has **${interaction.guild.memberCount}** members and was created on **${interaction.guild.createdAt}**. It is **${new Date().getFullYear() - interaction.guild.createdAt.getFullYear()}** years old.`, ); }, }; diff --git a/src/commands/util/user-info.ts b/src/commands/util/user-info.ts index 7f1ac68..f3d83d1 100644 --- a/src/commands/util/user-info.ts +++ b/src/commands/util/user-info.ts @@ -5,8 +5,8 @@ import { PermissionsBitField, } from 'discord.js'; -import { getMember } from '../../db/db.js'; -import { OptionsCommand } from '../../types/CommandTypes.js'; +import { getMember } from '@/db/db.js'; +import { OptionsCommand } from '@/types/CommandTypes.js'; const command: OptionsCommand = { data: new SlashCommandBuilder() @@ -19,21 +19,25 @@ const command: OptionsCommand = { .setRequired(true), ), execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + await interaction.deferReply(); + const userOption = interaction.options.get( 'user', ) as unknown as GuildMember; const user = userOption.user; if (!userOption || !user) { - await interaction.reply('User not found'); + await interaction.editReply('User not found'); return; } if ( - !interaction.memberPermissions!.has( + !interaction.memberPermissions?.has( PermissionsBitField.Flags.ModerateMembers, ) ) { - await interaction.reply( + await interaction.editReply( 'You do not have permission to view member information.', ); return; @@ -140,7 +144,7 @@ const command: OptionsCommand = { iconURL: interaction.user.displayAvatarURL(), }); - await interaction.reply({ embeds: [embed] }); + await interaction.editReply({ embeds: [embed] }); }, }; diff --git a/src/commands/util/xp.ts b/src/commands/util/xp.ts new file mode 100644 index 0000000..3a667c8 --- /dev/null +++ b/src/commands/util/xp.ts @@ -0,0 +1,130 @@ +import { SlashCommandBuilder } from 'discord.js'; + +import { SubcommandCommand } from '@/types/CommandTypes.js'; +import { addXpToUser, getUserLevel } from '@/db/db.js'; +import { loadConfig } from '@/util/configLoader.js'; + +const command: SubcommandCommand = { + data: new SlashCommandBuilder() + .setName('xp') + .setDescription('(Manager only) Manage user XP') + .addSubcommand((subcommand) => + subcommand + .setName('add') + .setDescription('Add XP to a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to add XP to') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to add') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('remove') + .setDescription('Remove XP from a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to remove XP from') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to remove') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('set') + .setDescription('Set XP for a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to set XP for') + .setRequired(true), + ) + .addIntegerOption((option) => + option + .setName('amount') + .setDescription('The amount of XP to set') + .setRequired(true), + ), + ) + .addSubcommand((subcommand) => + subcommand + .setName('reset') + .setDescription('Reset XP for a member') + .addUserOption((option) => + option + .setName('user') + .setDescription('The user to reset XP for') + .setRequired(true), + ), + ), + execute: async (interaction) => { + if (!interaction.isChatInputCommand() || !interaction.guild) return; + + const commandUser = interaction.guild.members.cache.get( + interaction.user.id, + ); + + await interaction.deferReply({ + flags: ['Ephemeral'], + }); + + const config = loadConfig(); + const managerRoleId = config.roles.staffRoles.find( + (role) => role.name === 'Manager', + )?.roleId; + + if ( + !commandUser || + !managerRoleId || + commandUser.roles.highest.comparePositionTo(managerRoleId) < 0 + ) { + await interaction.editReply({ + content: 'You do not have permission to use this command', + }); + return; + } + + const subcommand = interaction.options.getSubcommand(); + const user = interaction.options.getUser('user', true); + const amount = interaction.options.getInteger('amount', false); + + const userData = await getUserLevel(user.id); + + if (subcommand === 'add') { + await addXpToUser(user.id, amount!); + await interaction.editReply({ + content: `Added ${amount} XP to <@${user.id}>`, + }); + } else if (subcommand === 'remove') { + await addXpToUser(user.id, -amount!); + await interaction.editReply({ + content: `Removed ${amount} XP from <@${user.id}>`, + }); + } else if (subcommand === 'set') { + await addXpToUser(user.id, amount! - userData.xp); + await interaction.editReply({ + content: `Set ${amount} XP for <@${user.id}>`, + }); + } else if (subcommand === 'reset') { + await addXpToUser(user.id, userData.xp * -1); + await interaction.editReply({ + content: `Reset XP for <@${user.id}>`, + }); + } + }, +}; + +export default command; diff --git a/src/db/db.ts b/src/db/db.ts index ebc2db8..bc5005f 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,20 +1,47 @@ +// ======================== +// External Imports +// ======================== +import fs from 'node:fs'; +import path from 'node:path'; import pkg from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { eq } from 'drizzle-orm'; +import { Client } from 'discord.js'; +// ======================== +// Internal Imports +// ======================== import * as schema from './schema.js'; -import { loadConfig } from '../util/configLoader.js'; +import { loadConfig } from '@/util/configLoader.js'; import { del, exists, getJson, setJson } from './redis.js'; +import { + logManagerNotification, + NotificationType, + notifyManagers, +} from '@/util/notificationHandler.js'; +// ======================== +// Database Configuration +// ======================== const { Pool } = pkg; const config = loadConfig(); -const dbPool = new Pool({ - connectionString: config.dbConnectionString, - ssl: true, -}); -export const db = drizzle({ client: dbPool, schema }); +// Connection parameters +const MAX_DB_RETRY_ATTEMPTS = config.database.maxRetryAttempts; +const INITIAL_DB_RETRY_DELAY = config.database.retryDelay; +// ======================== +// Connection State Variables +// ======================== +let isDbConnected = false; +let connectionAttempts = 0; +let hasNotifiedDbDisconnect = false; +let discordClient: Client | null = null; +let dbPool: pkg.Pool; +export let db: ReturnType; + +/** + * Custom error class for database operations + */ class DatabaseError extends Error { constructor( message: string, @@ -25,208 +52,278 @@ class DatabaseError extends Error { } } -export async function getAllMembers() { +// ======================== +// Client Management +// ======================== + +/** + * Sets the Discord client for sending notifications + * @param client - The Discord client + */ +export function setDiscordClient(client: Client): void { + discordClient = client; +} + +// ======================== +// Connection Management +// ======================== + +/** + * Initializes the database connection with retry logic + * @returns Promise resolving to true if connected successfully, false otherwise + */ +export async function initializeDatabaseConnection(): Promise { try { - if (await exists('nonBotMembers')) { - const memberData = - await getJson<(typeof schema.memberTable.$inferSelect)[]>( - 'nonBotMembers', + // Check if existing connection is working + if (dbPool) { + try { + await dbPool.query('SELECT 1'); + isDbConnected = true; + return true; + } catch (error) { + console.warn( + 'Existing database connection is not responsive, creating a new one', ); - if (memberData && memberData.length > 0) { - return memberData; - } else { - await del('nonBotMembers'); - return await getAllMembers(); + try { + await dbPool.end(); + } catch (endError) { + console.error('Error ending pool:', endError); + } } - } else { - const nonBotMembers = await db - .select() - .from(schema.memberTable) - .where(eq(schema.memberTable.currentlyInServer, true)); - await setJson<(typeof schema.memberTable.$inferSelect)[]>( - 'nonBotMembers', - nonBotMembers, - ); - return nonBotMembers; } - } catch (error) { - console.error('Error getting all members: ', error); - throw new DatabaseError('Failed to get all members: ', error as Error); - } -} -export async function setMembers(nonBotMembers: any) { - try { - nonBotMembers.forEach(async (member: any) => { - const memberInfo = await db - .select() - .from(schema.memberTable) - .where(eq(schema.memberTable.discordId, member.user.id)); - if (memberInfo.length > 0) { - await updateMember({ - discordId: member.user.id, - discordUsername: member.user.username, - currentlyInServer: true, - }); - } else { - const members: typeof schema.memberTable.$inferInsert = { - discordId: member.user.id, - discordUsername: member.user.username, - }; - await db.insert(schema.memberTable).values(members); - } + // Log the database connection attempt + console.log( + `Connecting to database... (connectionString length: ${config.database.dbConnectionString.length})`, + ); + + // Create new connection pool + dbPool = new Pool({ + connectionString: config.database.dbConnectionString, + ssl: (() => { + try { + return { + ca: fs.readFileSync(path.resolve('./certs/psql-ca.crt')), + key: fs.readFileSync(path.resolve('./certs/psql-client.key')), + cert: fs.readFileSync(path.resolve('./certs/psql-server.crt')), + }; + } catch (error) { + console.warn( + 'Failed to load certificates for database, using insecure connection:', + error, + ); + return undefined; + } + })(), + connectionTimeoutMillis: 10000, }); + + // Test connection + await dbPool.query('SELECT 1'); + + // Initialize Drizzle ORM + db = drizzle({ client: dbPool, schema }); + + // Connection successful + console.info('Successfully connected to database'); + isDbConnected = true; + connectionAttempts = 0; + + // Send notification if connection was previously lost + if (hasNotifiedDbDisconnect && discordClient) { + logManagerNotification(NotificationType.DATABASE_CONNECTION_RESTORED); + notifyManagers( + discordClient, + NotificationType.DATABASE_CONNECTION_RESTORED, + ); + hasNotifiedDbDisconnect = false; + } + + return true; } catch (error) { - console.error('Error setting members: ', error); - throw new DatabaseError('Failed to set members: ', error as Error); - } -} + console.error('Failed to connect to database:', error); + isDbConnected = false; + connectionAttempts++; -export async function getMember(discordId: string) { - try { - if (await exists(`${discordId}-memberInfo`)) { - const cachedMember = await getJson< - typeof schema.memberTable.$inferSelect - >(`${discordId}-memberInfo`); - const cachedModerationHistory = await getJson< - (typeof schema.moderationTable.$inferSelect)[] - >(`${discordId}-moderationHistory`); - - if ( - cachedMember && - 'discordId' in cachedMember && - cachedModerationHistory && - cachedModerationHistory.length > 0 - ) { - return { - ...cachedMember, - moderations: cachedModerationHistory, - }; - } else { - await del(`${discordId}-memberInfo`); - await del(`${discordId}-moderationHistory`); - return await getMember(discordId); + // Handle max retry attempts exceeded + if (connectionAttempts >= MAX_DB_RETRY_ATTEMPTS) { + if (!hasNotifiedDbDisconnect && discordClient) { + const message = `Failed to connect to database after ${connectionAttempts} attempts.`; + console.error(message); + logManagerNotification( + NotificationType.DATABASE_CONNECTION_LOST, + `Error: ${error}`, + ); + notifyManagers( + discordClient, + NotificationType.DATABASE_CONNECTION_LOST, + `Connection attempts exhausted after ${connectionAttempts} tries. The bot cannot function without database access and will now terminate.`, + ); + hasNotifiedDbDisconnect = true; } - } else { - const member = await db.query.memberTable.findFirst({ - where: eq(schema.memberTable.discordId, discordId), - with: { - moderations: true, - }, - }); - await setJson( - `${discordId}-memberInfo`, - member!, - ); - await setJson<(typeof schema.moderationTable.$inferSelect)[]>( - `${discordId}-moderationHistory`, - member!.moderations, - ); + // Terminate after sending notifications + setTimeout(() => { + console.error('Database connection failed, shutting down bot'); + process.exit(1); + }, 3000); - return member; + return false; } - } catch (error) { - console.error('Error getting member: ', error); - throw new DatabaseError('Failed to get member: ', error as Error); + + // Retry connection with exponential backoff + const delay = Math.min( + INITIAL_DB_RETRY_DELAY * Math.pow(2, connectionAttempts - 1), + 30000, + ); + console.log( + `Retrying database connection in ${delay}ms... (Attempt ${connectionAttempts}/${MAX_DB_RETRY_ATTEMPTS})`, + ); + + setTimeout(initializeDatabaseConnection, delay); + + return false; } } -export async function updateMember({ - discordId, - discordUsername, - currentlyInServer, - currentlyBanned, -}: schema.memberTableTypes) { - try { - const result = await db - .update(schema.memberTable) - .set({ - discordUsername, - currentlyInServer, - currentlyBanned, - }) - .where(eq(schema.memberTable.discordId, discordId)); +// Initialize database connection +let dbInitPromise = initializeDatabaseConnection().catch((error) => { + console.error('Failed to initialize database connection:', error); + process.exit(1); +}); - if (await exists(`${discordId}-memberInfo`)) { - await del(`${discordId}-memberInfo`); - } - if (await exists('nonBotMembers')) { - await del('nonBotMembers'); - } +// ======================== +// Helper Functions +// ======================== - return result; - } catch (error) { - console.error('Error updating member: ', error); - throw new DatabaseError('Failed to update member: ', error as Error); +/** + * Ensures the database is initialized and returns a promise + * @returns Promise for database initialization + */ +export async function ensureDbInitialized(): Promise { + await dbInitPromise; + + if (!isDbConnected) { + dbInitPromise = initializeDatabaseConnection(); + await dbInitPromise; } } -export async function updateMemberModerationHistory({ - discordId, - moderatorDiscordId, - action, - reason, - duration, - createdAt, - expiresAt, - active, -}: schema.moderationTableTypes) { +/** + * Checks if the database connection is active and working + * @returns Promise resolving to true if connected, false otherwise + */ +export async function ensureDatabaseConnection(): Promise { + await ensureDbInitialized(); + + if (!isDbConnected) { + return await initializeDatabaseConnection(); + } + try { - const moderationEntry = { - discordId, - moderatorDiscordId, - action, - reason, - duration, - createdAt, - expiresAt, - active, - }; - const result = await db - .insert(schema.moderationTable) - .values(moderationEntry); - - if (await exists(`${discordId}-moderationHistory`)) { - await del(`${discordId}-moderationHistory`); - } - if (await exists(`${discordId}-memberInfo`)) { - await del(`${discordId}-memberInfo`); - } - - return result; + await dbPool.query('SELECT 1'); + return true; } catch (error) { - console.error('Error updating moderation history: ', error); - throw new DatabaseError( - 'Failed to update moderation history: ', - error as Error, + console.error('Database connection test failed:', error); + isDbConnected = false; + return await initializeDatabaseConnection(); + } +} + +/** + * Generic error handler for database operations + * @param errorMessage - Error message to log + * @param error - Original error object + * @throws {DatabaseError} - Always throws a wrapped database error + */ +export const handleDbError = (errorMessage: string, error: Error): never => { + console.error(`${errorMessage}:`, error); + + // Check if error is related to connection and attempt to reconnect + if ( + error.message.includes('connection') || + error.message.includes('connect') + ) { + isDbConnected = false; + ensureDatabaseConnection().catch((err) => { + console.error('Failed to reconnect to database:', err); + }); + } + + throw new DatabaseError(errorMessage, error); +}; + +// ======================== +// Cache Management +// ======================== + +/** + * Checks and retrieves cached data or fetches from database + * @param cacheKey - Key to check in cache + * @param dbFetch - Function to fetch data from database + * @param ttl - Time to live for cache in seconds + * @returns Cached or freshly fetched data + */ +export async function withCache( + cacheKey: string, + dbFetch: () => Promise, + ttl?: number, +): Promise { + try { + const cachedData = await getJson(cacheKey); + if (cachedData !== null) { + return cachedData; + } + } catch (error) { + console.warn( + `Cache retrieval failed for ${cacheKey}, falling back to database:`, + error, ); } + + const data = await dbFetch(); + + try { + await setJson(cacheKey, data, ttl); + } catch (error) { + console.warn(`Failed to cache data for ${cacheKey}:`, error); + } + + return data; } -export async function getMemberModerationHistory(discordId: string) { +/** + * Invalidates a cache key if it exists + * @param cacheKey - Key to invalidate + */ +export async function invalidateCache(cacheKey: string): Promise { try { - if (await exists(`${discordId}-moderationHistory`)) { - return await getJson<(typeof schema.moderationTable.$inferSelect)[]>( - `${discordId}-moderationHistory`, - ); - } else { - const moderationHistory = await db - .select() - .from(schema.moderationTable) - .where(eq(schema.moderationTable.discordId, discordId)); - - await setJson<(typeof schema.moderationTable.$inferSelect)[]>( - `${discordId}-moderationHistory`, - moderationHistory, - ); - return moderationHistory; + if (await exists(cacheKey)) { + await del(cacheKey); } } catch (error) { - console.error('Error getting moderation history: ', error); - throw new DatabaseError( - 'Failed to get moderation history: ', - error as Error, - ); + console.warn(`Error invalidating cache for key ${cacheKey}:`, error); } } + +// ======================== +// Database Functions Exports +// ======================== + +// Achievement related functions +export * from './functions/achievementFunctions.js'; + +// Facts system functions +export * from './functions/factFunctions.js'; + +// Giveaway management functions +export * from './functions/giveawayFunctions.js'; + +// User leveling system functions +export * from './functions/levelFunctions.js'; + +// Guild member management functions +export * from './functions/memberFunctions.js'; + +// Moderation and administration functions +export * from './functions/moderationFunctions.js'; diff --git a/src/db/functions/achievementFunctions.ts b/src/db/functions/achievementFunctions.ts new file mode 100644 index 0000000..30cbc3b --- /dev/null +++ b/src/db/functions/achievementFunctions.ts @@ -0,0 +1,282 @@ +import { and, eq } from 'drizzle-orm'; + +import { db, ensureDbInitialized, handleDbError } from '../db.js'; +import * as schema from '../schema.js'; + +/** + * Get all achievement definitions + * @returns Array of achievement definitions + */ +export async function getAllAchievements(): Promise< + schema.achievementDefinitionsTableTypes[] +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get achievements'); + return []; + } + + return await db + .select() + .from(schema.achievementDefinitionsTable) + .orderBy(schema.achievementDefinitionsTable.threshold); + } catch (error) { + return handleDbError('Failed to get all achievements', error as Error); + } +} + +/** + * Get achievements for a specific user + * @param userId - Discord ID of the user + * @returns Array of user achievements + */ +export async function getUserAchievements( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user achievements'); + return []; + } + + return await db + .select({ + id: schema.userAchievementsTable.id, + discordId: schema.userAchievementsTable.discordId, + achievementId: schema.userAchievementsTable.achievementId, + earnedAt: schema.userAchievementsTable.earnedAt, + progress: schema.userAchievementsTable.progress, + }) + .from(schema.userAchievementsTable) + .where(eq(schema.userAchievementsTable.discordId, userId)); + } catch (error) { + return handleDbError('Failed to get user achievements', error as Error); + } +} + +/** + * Award an achievement to a user + * @param userId - Discord ID of the user + * @param achievementId - ID of the achievement + * @returns Boolean indicating success + */ +export async function awardAchievement( + userId: string, + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot award achievement'); + return false; + } + + const existing = await db + .select() + .from(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, userId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ) + .then((rows) => rows[0]); + + if (existing) { + if (existing.earnedAt) { + return false; + } + + await db + .update(schema.userAchievementsTable) + .set({ + earnedAt: new Date(), + progress: 100, + }) + .where(eq(schema.userAchievementsTable.id, existing.id)); + } else { + await db.insert(schema.userAchievementsTable).values({ + discordId: userId, + achievementId: achievementId, + earnedAt: new Date(), + progress: 100, + }); + } + + return true; + } catch (error) { + handleDbError('Failed to award achievement', error as Error); + return false; + } +} + +/** + * Update achievement progress for a user + * @param userId - Discord ID of the user + * @param achievementId - ID of the achievement + * @param progress - Progress value (0-100) + * @returns Boolean indicating success + */ +export async function updateAchievementProgress( + userId: string, + achievementId: number, + progress: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot update achievement progress', + ); + return false; + } + + const existing = await db + .select() + .from(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, userId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ) + .then((rows) => rows[0]); + + if (existing) { + if (existing.earnedAt) { + return false; + } + + await db + .update(schema.userAchievementsTable) + .set({ + progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), + }) + .where(eq(schema.userAchievementsTable.id, existing.id)); + } else { + await db.insert(schema.userAchievementsTable).values({ + discordId: userId, + achievementId: achievementId, + progress: Math.floor(progress) > 100 ? 100 : Math.floor(progress), + }); + } + + return true; + } catch (error) { + handleDbError('Failed to update achievement progress', error as Error); + return false; + } +} + +/** + * Create a new achievement definition + * @param achievementData - Achievement definition data + * @returns Created achievement or undefined on failure + */ +export async function createAchievement(achievementData: { + name: string; + description: string; + imageUrl?: string; + requirementType: string; + threshold: number; + requirement?: any; + rewardType?: string; + rewardValue?: string; +}): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot create achievement'); + return undefined; + } + + const [achievement] = await db + .insert(schema.achievementDefinitionsTable) + .values({ + name: achievementData.name, + description: achievementData.description, + imageUrl: achievementData.imageUrl || null, + requirementType: achievementData.requirementType, + threshold: achievementData.threshold, + requirement: achievementData.requirement || {}, + rewardType: achievementData.rewardType || null, + rewardValue: achievementData.rewardValue || null, + }) + .returning(); + + return achievement; + } catch (error) { + return handleDbError('Failed to create achievement', error as Error); + } +} + +/** + * Delete an achievement definition + * @param achievementId - ID of the achievement to delete + * @returns Boolean indicating success + */ +export async function deleteAchievement( + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot delete achievement'); + return false; + } + + await db + .delete(schema.userAchievementsTable) + .where(eq(schema.userAchievementsTable.achievementId, achievementId)); + + await db + .delete(schema.achievementDefinitionsTable) + .where(eq(schema.achievementDefinitionsTable.id, achievementId)); + + return true; + } catch (error) { + handleDbError('Failed to delete achievement', error as Error); + return false; + } +} + +/** + * Removes an achievement from a user + * @param discordId - Discord user ID + * @param achievementId - Achievement ID to remove + * @returns boolean indicating success + */ +export async function removeUserAchievement( + discordId: string, + achievementId: number, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot remove user achievement'); + return false; + } + + await db + .delete(schema.userAchievementsTable) + .where( + and( + eq(schema.userAchievementsTable.discordId, discordId), + eq(schema.userAchievementsTable.achievementId, achievementId), + ), + ); + return true; + } catch (error) { + handleDbError('Failed to remove user achievement', error as Error); + return false; + } +} diff --git a/src/db/functions/factFunctions.ts b/src/db/functions/factFunctions.ts new file mode 100644 index 0000000..10134f3 --- /dev/null +++ b/src/db/functions/factFunctions.ts @@ -0,0 +1,198 @@ +import { and, eq, isNull, sql } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; + +/** + * Add a new fact to the database + * @param content - Content of the fact + * @param source - Source of the fact + * @param addedBy - Discord ID of the user who added the fact + * @param approved - Whether the fact is approved or not + */ +export async function addFact({ + content, + source, + addedBy, + approved = false, +}: schema.factTableTypes): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot add fact'); + } + + await db.insert(schema.factTable).values({ + content, + source, + addedBy, + approved, + }); + + await invalidateCache('unused-facts'); + } catch (error) { + handleDbError('Failed to add fact', error as Error); + } +} + +/** + * Get the ID of the most recently added fact + * @returns ID of the last inserted fact + */ +export async function getLastInsertedFactId(): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get last inserted fact'); + } + + const result = await db + .select({ id: sql`MAX(${schema.factTable.id})` }) + .from(schema.factTable); + + return result[0]?.id ?? 0; + } catch (error) { + return handleDbError('Failed to get last inserted fact ID', error as Error); + } +} + +/** + * Get a random fact that hasn't been used yet + * @returns Random fact object + */ +export async function getRandomUnusedFact(): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get random unused fact'); + } + + const cacheKey = 'unused-facts'; + const facts = await withCache( + cacheKey, + async () => { + return (await db + .select() + .from(schema.factTable) + .where( + and( + eq(schema.factTable.approved, true), + isNull(schema.factTable.usedOn), + ), + )) as schema.factTableTypes[]; + }, + ); + + if (facts.length === 0) { + await db + .update(schema.factTable) + .set({ usedOn: null }) + .where(eq(schema.factTable.approved, true)); + + await invalidateCache(cacheKey); + return await getRandomUnusedFact(); + } + + return facts[ + Math.floor(Math.random() * facts.length) + ] as schema.factTableTypes; + } catch (error) { + return handleDbError('Failed to get random fact', error as Error); + } +} + +/** + * Mark a fact as used + * @param id - ID of the fact to mark as used + */ +export async function markFactAsUsed(id: number): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot mark fact as used'); + } + + await db + .update(schema.factTable) + .set({ usedOn: new Date() }) + .where(eq(schema.factTable.id, id)); + + await invalidateCache('unused-facts'); + } catch (error) { + handleDbError('Failed to mark fact as used', error as Error); + } +} + +/** + * Get all pending facts that need approval + * @returns Array of pending fact objects + */ +export async function getPendingFacts(): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get pending facts'); + } + + return (await db + .select() + .from(schema.factTable) + .where(eq(schema.factTable.approved, false))) as schema.factTableTypes[]; + } catch (error) { + return handleDbError('Failed to get pending facts', error as Error); + } +} + +/** + * Approve a fact + * @param id - ID of the fact to approve + */ +export async function approveFact(id: number): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot approve fact'); + } + + await db + .update(schema.factTable) + .set({ approved: true }) + .where(eq(schema.factTable.id, id)); + + await invalidateCache('unused-facts'); + } catch (error) { + handleDbError('Failed to approve fact', error as Error); + } +} + +/** + * Delete a fact + * @param id - ID of the fact to delete + */ +export async function deleteFact(id: number): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot delete fact'); + } + + await db.delete(schema.factTable).where(eq(schema.factTable.id, id)); + + await invalidateCache('unused-facts'); + } catch (error) { + return handleDbError('Failed to delete fact', error as Error); + } +} diff --git a/src/db/functions/giveawayFunctions.ts b/src/db/functions/giveawayFunctions.ts new file mode 100644 index 0000000..354b07f --- /dev/null +++ b/src/db/functions/giveawayFunctions.ts @@ -0,0 +1,275 @@ +import { eq } from 'drizzle-orm'; + +import { db, ensureDbInitialized, handleDbError } from '../db.js'; +import { selectGiveawayWinners } from '@/util/giveaways/utils.js'; +import * as schema from '../schema.js'; + +/** + * Create a giveaway in the database + * @param giveawayData - Data for the giveaway + * @returns Created giveaway object + */ +export async function createGiveaway(giveawayData: { + channelId: string; + messageId: string; + endAt: Date; + prize: string; + winnerCount: number; + hostId: string; + requirements?: { + level?: number; + roleId?: string; + messageCount?: number; + requireAll?: boolean; + }; + bonuses?: { + roles?: Array<{ id: string; entries: number }>; + levels?: Array<{ threshold: number; entries: number }>; + messages?: Array<{ threshold: number; entries: number }>; + }; +}): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot create giveaway'); + } + + const [giveaway] = await db + .insert(schema.giveawayTable) + .values({ + channelId: giveawayData.channelId, + messageId: giveawayData.messageId, + endAt: giveawayData.endAt, + prize: giveawayData.prize, + winnerCount: giveawayData.winnerCount, + hostId: giveawayData.hostId, + requiredLevel: giveawayData.requirements?.level, + requiredRoleId: giveawayData.requirements?.roleId, + requiredMessageCount: giveawayData.requirements?.messageCount, + requireAllCriteria: giveawayData.requirements?.requireAll ?? true, + bonusEntries: + giveawayData.bonuses as schema.giveawayTableTypes['bonusEntries'], + }) + .returning(); + + return giveaway as schema.giveawayTableTypes; + } catch (error) { + return handleDbError('Failed to create giveaway', error as Error); + } +} + +/** + * Get a giveaway by ID or message ID + * @param id - ID of the giveaway + * @param isDbId - Whether the ID is a database ID + * @returns Giveaway object or undefined if not found + */ +export async function getGiveaway( + id: string | number, + isDbId = false, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get giveaway'); + return undefined; + } + + if (isDbId) { + const numId = typeof id === 'string' ? parseInt(id) : id; + const [giveaway] = await db + .select() + .from(schema.giveawayTable) + .where(eq(schema.giveawayTable.id, numId)) + .limit(1); + + return giveaway as schema.giveawayTableTypes; + } else { + const [giveaway] = await db + .select() + .from(schema.giveawayTable) + .where(eq(schema.giveawayTable.messageId, id as string)) + .limit(1); + + return giveaway as schema.giveawayTableTypes; + } + } catch (error) { + return handleDbError('Failed to get giveaway', error as Error); + } +} + +/** + * Get all active giveaways + * @returns Array of active giveaway objects + */ +export async function getActiveGiveaways(): Promise< + schema.giveawayTableTypes[] +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get active giveaways'); + } + + return (await db + .select() + .from(schema.giveawayTable) + .where( + eq(schema.giveawayTable.status, 'active'), + )) as schema.giveawayTableTypes[]; + } catch (error) { + return handleDbError('Failed to get active giveaways', error as Error); + } +} + +/** + * Update giveaway participants + * @param messageId - ID of the giveaway message + * @param userId - ID of the user to add + * @param entries - Number of entries to add + * @return 'success' | 'already_entered' | 'inactive' | 'error' + */ +export async function addGiveawayParticipant( + messageId: string, + userId: string, + entries = 1, +): Promise<'success' | 'already_entered' | 'inactive' | 'error'> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot add participant'); + return 'error'; + } + + const giveaway = await getGiveaway(messageId); + if (!giveaway || giveaway.status !== 'active') { + return 'inactive'; + } + + if (giveaway.participants?.includes(userId)) { + return 'already_entered'; + } + + const participants = [...(giveaway.participants || [])]; + for (let i = 0; i < entries; i++) { + participants.push(userId); + } + + await db + .update(schema.giveawayTable) + .set({ participants: participants }) + .where(eq(schema.giveawayTable.messageId, messageId)); + + return 'success'; + } catch (error) { + handleDbError('Failed to add giveaway participant', error as Error); + return 'error'; + } +} + +/** + * End a giveaway + * @param id - ID of the giveaway + * @param isDbId - Whether the ID is a database ID + * @param forceWinners - Array of user IDs to force as winners + * @return Updated giveaway object + */ +export async function endGiveaway( + id: string | number, + isDbId = false, + forceWinners?: string[], +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot end giveaway'); + return undefined; + } + + const giveaway = await getGiveaway(id, isDbId); + if (!giveaway || giveaway.status !== 'active' || !giveaway.participants) { + return undefined; + } + + const winners = selectGiveawayWinners( + giveaway.participants, + giveaway.winnerCount, + forceWinners, + ); + + const [updatedGiveaway] = await db + .update(schema.giveawayTable) + .set({ + status: 'ended', + winnersIds: winners, + }) + .where(eq(schema.giveawayTable.id, giveaway.id)) + .returning(); + + return updatedGiveaway as schema.giveawayTableTypes; + } catch (error) { + return handleDbError('Failed to end giveaway', error as Error); + } +} + +/** + * Reroll winners for a giveaway + * @param id - ID of the giveaway + * @return Updated giveaway object + */ +export async function rerollGiveaway( + id: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot reroll giveaway'); + return undefined; + } + + const giveaway = await getGiveaway(id, true); + if ( + !giveaway || + !giveaway.participants || + giveaway.participants.length === 0 || + giveaway.status !== 'ended' + ) { + console.warn( + `Cannot reroll giveaway ${id}: Not found, no participants, or not ended.`, + ); + return undefined; + } + + const newWinners = selectGiveawayWinners( + giveaway.participants, + giveaway.winnerCount, + undefined, + giveaway.winnersIds ?? [], + ); + + if (newWinners.length === 0) { + console.warn( + `Cannot reroll giveaway ${id}: No eligible participants left after excluding previous winners.`, + ); + return giveaway; + } + + const [updatedGiveaway] = await db + .update(schema.giveawayTable) + .set({ + winnersIds: newWinners, + }) + .where(eq(schema.giveawayTable.id, giveaway.id)) + .returning(); + + return updatedGiveaway as schema.giveawayTableTypes; + } catch (error) { + return handleDbError('Failed to reroll giveaway', error as Error); + } +} diff --git a/src/db/functions/levelFunctions.ts b/src/db/functions/levelFunctions.ts new file mode 100644 index 0000000..3bfd42a --- /dev/null +++ b/src/db/functions/levelFunctions.ts @@ -0,0 +1,329 @@ +import { desc, eq } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; +import { calculateLevelFromXp } from '@/util/levelingSystem.js'; + +/** + * Get user level information or create a new entry if not found + * @param discordId - Discord ID of the user + * @returns User level object + */ +export async function getUserLevel( + discordId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user level'); + } + + const cacheKey = `level-${discordId}`; + + return await withCache( + cacheKey, + async () => { + const level = await db + .select() + .from(schema.levelTable) + .where(eq(schema.levelTable.discordId, discordId)) + .then((rows) => rows[0]); + + if (level) { + return { + ...level, + lastMessageTimestamp: level.lastMessageTimestamp ?? undefined, + }; + } + + const newLevel: schema.levelTableTypes = { + discordId, + xp: 0, + level: 0, + lastMessageTimestamp: new Date(), + messagesSent: 0, + reactionCount: 0, + }; + + await db.insert(schema.levelTable).values(newLevel); + return newLevel; + }, + 300, + ); + } catch (error) { + return handleDbError('Error getting user level', error as Error); + } +} + +/** + * Add XP to a user, updating their level if necessary + * @param discordId - Discord ID of the user + * @param amount - Amount of XP to add + */ +export async function addXpToUser( + discordId: string, + amount: number, +): Promise<{ + leveledUp: boolean; + newLevel: number; + oldLevel: number; + messagesSent: number; +}> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot add xp to user'); + } + + const cacheKey = `level-${discordId}`; + const userData = await getUserLevel(discordId); + const currentLevel = userData.level; + const currentXp = Number(userData.xp); + const xpToAdd = Number(amount); + + userData.xp = currentXp + xpToAdd; + + userData.lastMessageTimestamp = new Date(); + userData.level = calculateLevelFromXp(userData.xp); + userData.messagesSent += 1; + + await invalidateLeaderboardCache(); + await invalidateCache(cacheKey); + await withCache( + cacheKey, + async () => { + const result = await db + .update(schema.levelTable) + .set({ + xp: userData.xp, + level: userData.level, + lastMessageTimestamp: userData.lastMessageTimestamp, + messagesSent: userData.messagesSent, + }) + .where(eq(schema.levelTable.discordId, discordId)) + .returning(); + + return result[0] as schema.levelTableTypes; + }, + 300, + ); + + return { + leveledUp: userData.level > currentLevel, + newLevel: userData.level, + oldLevel: currentLevel, + messagesSent: userData.messagesSent, + }; + } catch (error) { + return handleDbError('Error adding XP to user', error as Error); + } +} + +/** + * Get a user's rank on the XP leaderboard + * @param discordId - Discord ID of the user + * @returns User's rank on the leaderboard + */ +export async function getUserRank(discordId: string): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user rank'); + } + + const leaderboardCache = await getLeaderboardData(); + + if (leaderboardCache) { + const userIndex = leaderboardCache.findIndex( + (member) => member.discordId === discordId, + ); + + if (userIndex !== -1) { + return userIndex + 1; + } + } + + return 1; + } catch (error) { + return handleDbError('Failed to get user rank', error as Error); + } +} + +/** + * Clear leaderboard cache + */ +export async function invalidateLeaderboardCache(): Promise { + await invalidateCache('xp-leaderboard'); +} + +/** + * Helper function to get or create leaderboard data + * @returns Array of leaderboard data + */ +async function getLeaderboardData(): Promise< + Array<{ + discordId: string; + xp: number; + }> +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get leaderboard data'); + } + + const cacheKey = 'xp-leaderboard'; + return withCache>( + cacheKey, + async () => { + return await db + .select({ + discordId: schema.levelTable.discordId, + xp: schema.levelTable.xp, + }) + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)); + }, + 300, + ); + } catch (error) { + return handleDbError('Failed to get leaderboard data', error as Error); + } +} + +/** + * Increments the user's reaction count + * @param userId - Discord user ID + * @returns The updated reaction count + */ +export async function incrementUserReactionCount( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot increment reaction count', + ); + } + + const levelData = await getUserLevel(userId); + + const newCount = (levelData.reactionCount || 0) + 1; + await db + .update(schema.levelTable) + .set({ reactionCount: newCount }) + .where(eq(schema.levelTable.discordId, userId)); + await invalidateCache(`level-${userId}`); + + return newCount; + } catch (error) { + console.error('Error incrementing user reaction count:', error); + return 0; + } +} + +/** + * Decrements the user's reaction count (but not below zero) + * @param userId - Discord user ID + * @returns The updated reaction count + */ +export async function decrementUserReactionCount( + userId: string, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot increment reaction count', + ); + } + + const levelData = await getUserLevel(userId); + + const newCount = Math.max(0, levelData.reactionCount - 1); + await db + .update(schema.levelTable) + .set({ reactionCount: newCount < 0 ? 0 : newCount }) + .where(eq(schema.levelTable.discordId, userId)); + await invalidateCache(`level-${userId}`); + + return newCount; + } catch (error) { + console.error('Error decrementing user reaction count:', error); + return 0; + } +} + +/** + * Gets the user's reaction count + * @param userId - Discord user ID + * @returns The user's reaction count + */ +export async function getUserReactionCount(userId: string): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get user reaction count'); + } + + const levelData = await getUserLevel(userId); + return levelData.reactionCount; + } catch (error) { + console.error('Error getting user reaction count:', error); + return 0; + } +} + +/** + * Get the XP leaderboard + * @param limit - Number of entries to return + * @returns Array of leaderboard entries + */ +export async function getLevelLeaderboard( + limit = 10, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get level leaderboard'); + } + + const leaderboardCache = await getLeaderboardData(); + + if (leaderboardCache) { + const limitedCache = leaderboardCache.slice(0, limit); + + const fullLeaderboard = await Promise.all( + limitedCache.map(async (entry) => { + const userData = await getUserLevel(entry.discordId); + return userData; + }), + ); + + return fullLeaderboard; + } + + return (await db + .select() + .from(schema.levelTable) + .orderBy(desc(schema.levelTable.xp)) + .limit(limit)) as schema.levelTableTypes[]; + } catch (error) { + return handleDbError('Failed to get leaderboard', error as Error); + } +} diff --git a/src/db/functions/memberFunctions.ts b/src/db/functions/memberFunctions.ts new file mode 100644 index 0000000..3ed416d --- /dev/null +++ b/src/db/functions/memberFunctions.ts @@ -0,0 +1,162 @@ +import { Collection, GuildMember } from 'discord.js'; +import { eq } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; +import { getMemberModerationHistory } from './moderationFunctions.js'; + +/** + * Get all non-bot members currently in the server + * @returns Array of member objects + */ +export async function getAllMembers() { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get members'); + } + + const cacheKey = 'nonBotMembers'; + return await withCache(cacheKey, async () => { + const nonBotMembers = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.currentlyInServer, true)); + return nonBotMembers; + }); + } catch (error) { + return handleDbError('Failed to get all members', error as Error); + } +} + +/** + * Set or update multiple members at once + * @param nonBotMembers - Array of member objects + */ +export async function setMembers( + nonBotMembers: Collection, +): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot set members'); + } + + await Promise.all( + nonBotMembers.map(async (member) => { + const memberInfo = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.discordId, member.user.id)); + + if (memberInfo.length > 0) { + await updateMember({ + discordId: member.user.id, + discordUsername: member.user.username, + currentlyInServer: true, + }); + } else { + const members: typeof schema.memberTable.$inferInsert = { + discordId: member.user.id, + discordUsername: member.user.username, + }; + await db.insert(schema.memberTable).values(members); + } + }), + ); + } catch (error) { + handleDbError('Failed to set members', error as Error); + } +} + +/** + * Get detailed information about a specific member including moderation history + * @param discordId - Discord ID of the user + * @returns Member object with moderation history + */ +export async function getMember( + discordId: string, +): Promise< + | (schema.memberTableTypes & { moderations: schema.moderationTableTypes[] }) + | undefined +> { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot get member'); + } + + const cacheKey = `${discordId}-memberInfo`; + + const member = await withCache( + cacheKey, + async () => { + const memberData = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.discordId, discordId)) + .then((rows) => rows[0]); + + return memberData as schema.memberTableTypes; + }, + ); + + const moderations = await getMemberModerationHistory(discordId); + + return { + ...member, + moderations, + }; + } catch (error) { + return handleDbError('Failed to get member', error as Error); + } +} + +/** + * Update a member's information in the database + * @param discordId - Discord ID of the user + * @param discordUsername - New username of the member + * @param currentlyInServer - Whether the member is currently in the server + * @param currentlyBanned - Whether the member is currently banned + */ +export async function updateMember({ + discordId, + discordUsername, + currentlyInServer, + currentlyBanned, + currentlyMuted, +}: schema.memberTableTypes): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error('Database not initialized, cannot update member'); + } + + await db + .update(schema.memberTable) + .set({ + discordUsername, + currentlyInServer, + currentlyBanned, + currentlyMuted, + }) + .where(eq(schema.memberTable.discordId, discordId)); + + await Promise.all([ + invalidateCache(`${discordId}-memberInfo`), + invalidateCache('nonBotMembers'), + ]); + } catch (error) { + handleDbError('Failed to update member', error as Error); + } +} diff --git a/src/db/functions/moderationFunctions.ts b/src/db/functions/moderationFunctions.ts new file mode 100644 index 0000000..601a9ad --- /dev/null +++ b/src/db/functions/moderationFunctions.ts @@ -0,0 +1,96 @@ +import { eq } from 'drizzle-orm'; + +import { + db, + ensureDbInitialized, + handleDbError, + invalidateCache, + withCache, +} from '../db.js'; +import * as schema from '../schema.js'; + +/** + * Add a new moderation action to a member's history + * @param discordId - Discord ID of the user + * @param moderatorDiscordId - Discord ID of the moderator + * @param action - Type of action taken + * @param reason - Reason for the action + * @param duration - Duration of the action + * @param createdAt - Timestamp of when the action was taken + * @param expiresAt - Timestamp of when the action expires + * @param active - Wether the action is active or not + */ +export async function updateMemberModerationHistory({ + discordId, + moderatorDiscordId, + action, + reason, + duration, + createdAt, + expiresAt, + active, +}: schema.moderationTableTypes): Promise { + try { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, update member moderation history', + ); + } + + const moderationEntry = { + discordId, + moderatorDiscordId, + action, + reason, + duration, + createdAt, + expiresAt, + active, + }; + + await db.insert(schema.moderationTable).values(moderationEntry); + + await Promise.all([ + invalidateCache(`${discordId}-moderationHistory`), + invalidateCache(`${discordId}-memberInfo`), + ]); + } catch (error) { + handleDbError('Failed to update moderation history', error as Error); + } +} + +/** + * Get a member's moderation history + * @param discordId - Discord ID of the user + * @returns Array of moderation actions + */ +export async function getMemberModerationHistory( + discordId: string, +): Promise { + await ensureDbInitialized(); + + if (!db) { + console.error( + 'Database not initialized, cannot get member moderation history', + ); + } + + const cacheKey = `${discordId}-moderationHistory`; + + try { + return await withCache( + cacheKey, + async () => { + const history = await db + .select() + .from(schema.moderationTable) + .where(eq(schema.moderationTable.discordId, discordId)); + return history as schema.moderationTableTypes[]; + }, + ); + } catch (error) { + return handleDbError('Failed to get moderation history', error as Error); + } +} diff --git a/src/db/redis.ts b/src/db/redis.ts index 8938d17..8348190 100644 --- a/src/db/redis.ts +++ b/src/db/redis.ts @@ -1,9 +1,33 @@ +import fs from 'node:fs'; +import path from 'node:path'; import Redis from 'ioredis'; -import { loadConfig } from '../util/configLoader.js'; +import { Client } from 'discord.js'; + +import { loadConfig } from '@/util/configLoader.js'; +import { + logManagerNotification, + NotificationType, + notifyManagers, +} from '@/util/notificationHandler.js'; const config = loadConfig(); -const redis = new Redis(config.redisConnectionString); +// Redis connection state +let isRedisAvailable = false; +let redis: Redis; +let connectionAttempts = 0; +const MAX_RETRY_ATTEMPTS = config.redis.retryAttempts; +const INITIAL_RETRY_DELAY = config.redis.initialRetryDelay; +let hasNotifiedDisconnect = false; +let discordClient: Client | null = null; + +// ======================== +// Redis Utility Classes and Helper Functions +// ======================== + +/** + * Custom error class for Redis errors + */ class RedisError extends Error { constructor( message: string, @@ -14,77 +38,286 @@ class RedisError extends Error { } } -redis.on('error', (error) => { - console.error('Redis connection error:', error); - throw new RedisError('Failed to connect to Redis instance: ', error); -}); +/** + * Redis error handler + * @param errorMessage - The error message to log + * @param error - The error object + */ +const handleRedisError = (errorMessage: string, error: Error): null => { + console.error(`${errorMessage}:`, error); + throw new RedisError(errorMessage, error); +}; -redis.on('connect', () => { - console.log('Successfully connected to Redis'); -}); +/** + * Sets the Discord client for sending notifications + * @param client - The Discord client + */ +export function setDiscordClient(client: Client): void { + discordClient = client; +} +/** + * Initializes the Redis connection with retry logic + */ +async function initializeRedisConnection() { + try { + if (redis && redis.status !== 'end' && redis.status !== 'close') { + return; + } + + redis = new Redis(config.redis.redisConnectionString, { + retryStrategy(times) { + connectionAttempts = times; + if (times >= MAX_RETRY_ATTEMPTS) { + const message = `Failed to connect to Redis after ${times} attempts. Caching will be disabled.`; + console.warn(message); + + if (!hasNotifiedDisconnect && discordClient) { + logManagerNotification(NotificationType.REDIS_CONNECTION_LOST); + notifyManagers( + discordClient, + NotificationType.REDIS_CONNECTION_LOST, + `Connection attempts exhausted after ${times} tries. Caching is now disabled.`, + ); + hasNotifiedDisconnect = true; + } + + return null; + } + + const delay = Math.min(INITIAL_RETRY_DELAY * Math.pow(2, times), 30000); + console.log( + `Retrying Redis connection in ${delay}ms... (Attempt ${times + 1}/${MAX_RETRY_ATTEMPTS})`, + ); + return delay; + }, + maxRetriesPerRequest: 3, + enableOfflineQueue: true, + tls: (() => { + try { + return { + ca: fs.readFileSync(path.resolve('./certs/cache-ca.crt')), + key: fs.readFileSync(path.resolve('./certs/cache-client.key')), + cert: fs.readFileSync(path.resolve('./certs/cache-server.crt')), + }; + } catch (error) { + console.warn( + 'Failed to load certificates for cache, using insecure connection:', + error, + ); + return undefined; + } + })(), + }); + + // ======================== + // Redis Events + // ======================== + redis.on('error', (error: Error) => { + console.error('Redis Connection Error:', error); + isRedisAvailable = false; + }); + + redis.on('connect', () => { + console.info('Successfully connected to Redis'); + isRedisAvailable = true; + connectionAttempts = 0; + + if (hasNotifiedDisconnect && discordClient) { + logManagerNotification(NotificationType.REDIS_CONNECTION_RESTORED); + notifyManagers( + discordClient, + NotificationType.REDIS_CONNECTION_RESTORED, + ); + hasNotifiedDisconnect = false; + } + }); + + redis.on('close', () => { + console.warn('Redis connection closed'); + isRedisAvailable = false; + + // Try to reconnect after some time if we've not exceeded max attempts + if (connectionAttempts < MAX_RETRY_ATTEMPTS) { + const delay = Math.min( + INITIAL_RETRY_DELAY * Math.pow(2, connectionAttempts), + 30000, + ); + setTimeout(initializeRedisConnection, delay); + } else if (!hasNotifiedDisconnect && discordClient) { + logManagerNotification(NotificationType.REDIS_CONNECTION_LOST); + notifyManagers( + discordClient, + NotificationType.REDIS_CONNECTION_LOST, + 'Connection closed and max retry attempts reached.', + ); + hasNotifiedDisconnect = true; + } + }); + + redis.on('reconnecting', () => { + console.info('Attempting to reconnect to Redis...'); + }); + } catch (error) { + console.error('Failed to initialize Redis:', error); + isRedisAvailable = false; + + if (!hasNotifiedDisconnect && discordClient) { + logManagerNotification( + NotificationType.REDIS_CONNECTION_LOST, + `Error: ${error}`, + ); + notifyManagers( + discordClient, + NotificationType.REDIS_CONNECTION_LOST, + `Initialization error: ${error}`, + ); + hasNotifiedDisconnect = true; + } + } +} + +// Initialize Redis connection +initializeRedisConnection(); + +/** + * Check if Redis is currently available, and attempt to reconnect if not + * @returns - True if Redis is connected and available + */ +export async function ensureRedisConnection(): Promise { + if (!isRedisAvailable) { + await initializeRedisConnection(); + } + return isRedisAvailable; +} + +// ======================== +// Redis Functions +// ======================== + +/** + * Function to set a key in Redis + * @param key - The key to set + * @param value - The value to set + * @param ttl - The time to live for the key + * @returns - 'OK' if successful + */ export async function set( key: string, value: string, ttl?: number, -): Promise<'OK'> { - try { - await redis.set(key, value); - if (ttl) await redis.expire(key, ttl); - } catch (error) { - console.error('Redis set error: ', error); - throw new RedisError(`Failed to set key: ${key}, `, error as Error); +): Promise<'OK' | null> { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping set operation'); + return null; + } + + try { + await redis.set(`bot:${key}`, value); + if (ttl) await redis.expire(`bot:${key}`, ttl); + return 'OK'; + } catch (error) { + return handleRedisError(`Failed to set key: ${key}`, error as Error); } - return Promise.resolve('OK'); } +/** + * Function to set a key in Redis with a JSON value + * @param key - The key to set + * @param value - The value to set + * @param ttl - The time to live for the key + * @returns - 'OK' if successful + */ export async function setJson( key: string, value: T, ttl?: number, -): Promise<'OK'> { +): Promise<'OK' | null> { return await set(key, JSON.stringify(value), ttl); } -export async function incr(key: string): Promise { +/** + * Increments a key in Redis + * @param key - The key to increment + * @returns - The new value of the key, or null if Redis is unavailable + */ +export async function incr(key: string): Promise { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping increment operation'); + return null; + } + try { - return await redis.incr(key); + return await redis.incr(`bot:${key}`); } catch (error) { - console.error('Redis increment error: ', error); - throw new RedisError(`Failed to increment key: ${key}, `, error as Error); + return handleRedisError(`Failed to increment key: ${key}`, error as Error); } } -export async function exists(key: string): Promise { +/** + * Checks if a key exists in Redis + * @param key - The key to check + * @returns - True if the key exists, false otherwise, or null if Redis is unavailable + */ +export async function exists(key: string): Promise { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping exists operation'); + return null; + } + try { - return (await redis.exists(key)) === 1; + return (await redis.exists(`bot:${key}`)) === 1; } catch (error) { - console.error('Redis exists error: ', error); - throw new RedisError( - `Failed to check if key exists: ${key}, `, + return handleRedisError( + `Failed to check if key exists: ${key}`, error as Error, ); } } +/** + * Gets the value of a key in Redis + * @param key - The key to get + * @returns - The value of the key, or null if the key does not exist or Redis is unavailable + */ export async function get(key: string): Promise { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping get operation'); + return null; + } + try { - return await redis.get(key); + return await redis.get(`bot:${key}`); } catch (error) { - console.error('Redis get error: ', error); - throw new RedisError(`Failed to get key: ${key}, `, error as Error); + return handleRedisError(`Failed to get key: ${key}`, error as Error); } } -export async function mget(...keys: string[]): Promise<(string | null)[]> { +/** + * Gets the values of multiple keys in Redis + * @param keys - The keys to get + * @returns - The values of the keys, or null if Redis is unavailable + */ +export async function mget( + ...keys: string[] +): Promise<(string | null)[] | null> { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping mget operation'); + return null; + } + try { - return await redis.mget(keys); + return await redis.mget(...keys.map((key) => `bot:${key}`)); } catch (error) { - console.error('Redis mget error: ', error); - throw new RedisError(`Failed to get keys: ${keys}, `, error as Error); + return handleRedisError('Failed to get keys', error as Error); } } +/** + * Gets the value of a key in Redis and parses it as a JSON object + * @param key - The key to get + * @returns - The parsed JSON value of the key, or null if the key does not exist or Redis is unavailable + */ export async function getJson(key: string): Promise { const value = await get(key); if (!value) return null; @@ -95,11 +328,28 @@ export async function getJson(key: string): Promise { } } -export async function del(key: string): Promise { +/** + * Deletes a key in Redis + * @param key - The key to delete + * @returns - The number of keys that were deleted, or null if Redis is unavailable + */ +export async function del(key: string): Promise { + if (!(await ensureRedisConnection())) { + console.warn('Redis unavailable, skipping delete operation'); + return null; + } + try { - return await redis.del(key); + return await redis.del(`bot:${key}`); } catch (error) { - console.error('Redis del error: ', error); - throw new RedisError(`Failed to delete key: ${key}, `, error as Error); + return handleRedisError(`Failed to delete key: ${key}`, error as Error); } } + +/** + * Check if Redis is currently available + * @returns - True if Redis is connected and available + */ +export function isRedisConnected(): boolean { + return isRedisAvailable; +} diff --git a/src/db/schema.ts b/src/db/schema.ts index cbf6782..843decb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,11 +1,13 @@ import { boolean, integer, + json, + jsonb, pgTable, timestamp, varchar, } from 'drizzle-orm/pg-core'; -import { relations } from 'drizzle-orm'; +import { InferSelectModel, relations } from 'drizzle-orm'; export interface memberTableTypes { id?: number; @@ -25,6 +27,28 @@ export const memberTable = pgTable('members', { currentlyMuted: boolean('currently_muted').notNull().default(false), }); +export interface levelTableTypes { + id?: number; + discordId: string; + xp: number; + level: number; + messagesSent: number; + reactionCount: number; + lastMessageTimestamp?: Date; +} + +export const levelTable = pgTable('levels', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + discordId: varchar('discord_id') + .notNull() + .references(() => memberTable.discordId, { onDelete: 'cascade' }), + xp: integer('xp').notNull().default(0), + level: integer('level').notNull().default(0), + messagesSent: integer('messages_sent').notNull().default(0), + reactionCount: integer('reaction_count').notNull().default(0), + lastMessageTimestamp: timestamp('last_message_timestamp'), +}); + export interface moderationTableTypes { id?: number; discordId: string; @@ -51,8 +75,20 @@ export const moderationTable = pgTable('moderations', { active: boolean('active').notNull().default(true), }); -export const memberRelations = relations(memberTable, ({ many }) => ({ +export const memberRelations = relations(memberTable, ({ many, one }) => ({ moderations: many(moderationTable), + levels: one(levelTable, { + fields: [memberTable.discordId], + references: [levelTable.discordId], + }), + facts: many(factTable), +})); + +export const levelRelations = relations(levelTable, ({ one }) => ({ + member: one(memberTable, { + fields: [levelTable.discordId], + references: [memberTable.discordId], + }), })); export const moderationRelations = relations(moderationTable, ({ one }) => ({ @@ -61,3 +97,85 @@ export const moderationRelations = relations(moderationTable, ({ one }) => ({ references: [memberTable.discordId], }), })); + +export type factTableTypes = { + id?: number; + content: string; + source?: string; + addedBy: string; + addedAt?: Date; + approved?: boolean; + usedOn?: Date; +}; + +export const factTable = pgTable('facts', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + content: varchar('content').notNull(), + source: varchar('source'), + addedBy: varchar('added_by').notNull(), + addedAt: timestamp('added_at').defaultNow().notNull(), + approved: boolean('approved').default(false).notNull(), + usedOn: timestamp('used_on'), +}); + +export type giveawayTableTypes = InferSelectModel & { + bonusEntries: { + roles?: Array<{ id: string; entries: number }>; + levels?: Array<{ threshold: number; entries: number }>; + messages?: Array<{ threshold: number; entries: number }>; + }; +}; + +export const giveawayTable = pgTable('giveaways', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + channelId: varchar('channel_id').notNull(), + messageId: varchar('message_id').notNull().unique(), + createdAt: timestamp('created_at').defaultNow(), + endAt: timestamp('end_at').notNull(), + prize: varchar('prize').notNull(), + winnerCount: integer('winner_count').notNull().default(1), + hostId: varchar('host_id') + .references(() => memberTable.discordId) + .notNull(), + status: varchar('status').notNull().default('active'), + participants: varchar('participants').array().default([]), + winnersIds: varchar('winners_ids').array().default([]), + requiredLevel: integer('required_level'), + requiredRoleId: varchar('required_role_id'), + requiredMessageCount: integer('required_message_count'), + requireAllCriteria: boolean('require_all_criteria').default(true), + bonusEntries: jsonb('bonus_entries').default({}), +}); + +export type userAchievementsTableTypes = InferSelectModel< + typeof userAchievementsTable +>; + +export const userAchievementsTable = pgTable('user_achievements', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + discordId: varchar('user_id', { length: 50 }) + .notNull() + .references(() => memberTable.discordId), + achievementId: integer('achievement_id') + .notNull() + .references(() => achievementDefinitionsTable.id), + earnedAt: timestamp('earned_at'), + progress: integer().default(0), +}); + +export type achievementDefinitionsTableTypes = InferSelectModel< + typeof achievementDefinitionsTable +>; + +export const achievementDefinitionsTable = pgTable('achievement_definitions', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + name: varchar({ length: 100 }).notNull(), + description: varchar({ length: 255 }).notNull(), + imageUrl: varchar('image_url', { length: 255 }), + requirement: json().notNull(), + requirementType: varchar('requirement_type', { length: 50 }).notNull(), + threshold: integer().notNull(), + rewardType: varchar('reward_type', { length: 50 }), + rewardValue: varchar('reward_value', { length: 50 }), + createdAt: timestamp('created_at').defaultNow().notNull(), +}); diff --git a/src/discord-bot.ts b/src/discord-bot.ts index d7a5c81..0183107 100644 --- a/src/discord-bot.ts +++ b/src/discord-bot.ts @@ -1,6 +1,6 @@ import { GatewayIntentBits } from 'discord.js'; -import { ExtendedClient } from './structures/ExtendedClient.js'; -import { loadConfig } from './util/configLoader.js'; +import { ExtendedClient } from '@/structures/ExtendedClient.js'; +import { loadConfig } from '@/util/configLoader.js'; async function startBot() { try { @@ -12,8 +12,10 @@ async function startBot() { GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, GatewayIntentBits.GuildModeration, + GatewayIntentBits.GuildInvites, + GatewayIntentBits.MessageContent, + GatewayIntentBits.GuildMessageReactions, ], }, config, diff --git a/src/events/channelEvents.ts b/src/events/channelEvents.ts index 523eb29..0978017 100644 --- a/src/events/channelEvents.ts +++ b/src/events/channelEvents.ts @@ -7,9 +7,9 @@ import { PermissionOverwrites, } from 'discord.js'; -import { ChannelLogAction } from '../util/logging/types.js'; -import { Event } from '../types/EventTypes.js'; -import logAction from '../util/logging/logAction.js'; +import { ChannelLogAction } from '@/util/logging/types.js'; +import { Event } from '@/types/EventTypes.js'; +import logAction from '@/util/logging/logAction.js'; function arePermissionsEqual( oldPerms: Map, diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 328b10f..5792eb4 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,40 +1,224 @@ -import { Events, Interaction } from 'discord.js'; +import { + Events, + Interaction, + ButtonInteraction, + ModalSubmitInteraction, + StringSelectMenuInteraction, +} from 'discord.js'; -import { ExtendedClient } from '../structures/ExtendedClient.js'; -import { Event } from '../types/EventTypes.js'; +import { Event } from '@/types/EventTypes.js'; +import { approveFact, deleteFact } from '@/db/db.js'; +import * as GiveawayManager from '@/util/giveaways/giveawayManager.js'; +import { ExtendedClient } from '@/structures/ExtendedClient.js'; +import { safelyRespond, validateInteraction } from '@/util/helpers.js'; +import { processCommandAchievements } from '@/util/achievementManager.js'; export default { name: Events.InteractionCreate, execute: async (interaction: Interaction) => { - if (!interaction.isCommand()) return; - - const client = interaction.client as ExtendedClient; - const command = client.commands.get(interaction.commandName); - - if (!command) { - console.error( - `No command matching ${interaction.commandName} was found.`, - ); - return; - } + if (!(await validateInteraction(interaction))) return; try { - await command.execute(interaction); - } catch (error) { - console.error(`Error executing ${interaction.commandName}`); - console.error(error); - - if (interaction.replied || interaction.deferred) { - await interaction.followUp({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], - }); + if (interaction.isCommand()) { + await handleCommand(interaction); + } else if (interaction.isButton()) { + await handleButton(interaction); + } else if (interaction.isModalSubmit()) { + await handleModal(interaction); + } else if (interaction.isStringSelectMenu()) { + await handleSelectMenu(interaction); } else { - await interaction.reply({ - content: 'There was an error while executing this command!', - flags: ['Ephemeral'], - }); + console.warn('Unhandled interaction type:', interaction); } + } catch (error) { + handleInteractionError(error, interaction); } }, } as Event; + +async function handleCommand(interaction: Interaction) { + if (!interaction.isCommand()) return; + + const client = interaction.client as ExtendedClient; + const command = client.commands.get(interaction.commandName); + + if (!command) { + console.error(`No command matching ${interaction.commandName} was found.`); + return; + } + + if (interaction.isChatInputCommand()) { + await command.execute(interaction); + await processCommandAchievements( + interaction.user.id, + command.data.name, + interaction.guild!, + ); + } else if ( + interaction.isUserContextMenuCommand() || + interaction.isMessageContextMenuCommand() + ) { + // @ts-expect-error + await command.execute(interaction); + await processCommandAchievements( + interaction.user.id, + command.data.name, + interaction.guild!, + ); + } +} + +async function handleButton(interaction: Interaction) { + if (!interaction.isButton()) return; + + const { customId } = interaction; + + try { + const giveawayHandlers: Record< + string, + (buttonInteraction: ButtonInteraction) => Promise + > = { + giveaway_start_builder: GiveawayManager.builder.startGiveawayBuilder, + giveaway_next: GiveawayManager.builder.nextBuilderStep, + giveaway_previous: GiveawayManager.builder.previousBuilderStep, + giveaway_set_prize: GiveawayManager.modals.showPrizeModal, + giveaway_set_duration: GiveawayManager.dropdowns.showDurationSelect, + giveaway_set_winners: GiveawayManager.dropdowns.showWinnerSelect, + giveaway_set_requirements: GiveawayManager.modals.showRequirementsModal, + giveaway_toggle_logic: GiveawayManager.toggleRequirementLogic, + giveaway_set_channel: + (interaction.guild?.channels.cache.size ?? 0) > 25 + ? GiveawayManager.modals.showChannelSelectModal + : GiveawayManager.dropdowns.showChannelSelect, + giveaway_bonus_entries: GiveawayManager.modals.showBonusEntriesModal, + giveaway_set_ping_role: + (interaction.guild?.roles.cache.size ?? 0) > 25 + ? GiveawayManager.modals.showPingRoleSelectModal + : GiveawayManager.dropdowns.showPingRoleSelect, + giveaway_publish: GiveawayManager.publishGiveaway, + enter_giveaway: GiveawayManager.handlers.handleGiveawayEntry, + }; + + if (giveawayHandlers[customId]) { + await giveawayHandlers[customId](interaction); + return; + } + + if ( + customId.startsWith('approve_fact_') || + customId.startsWith('reject_fact_') + ) { + await handleFactModeration(interaction, customId); + return; + } + + console.warn('Unhandled button interaction:', customId); + } catch (error) { + throw new Error(`Button interaction failed: ${error}`); + } +} + +async function handleFactModeration( + interaction: Interaction, + customId: string, +) { + if (!interaction.isButton()) return; + if (!interaction.memberPermissions?.has('ModerateMembers')) { + await interaction.reply({ + content: 'You do not have permission to moderate facts.', + ephemeral: true, + }); + return; + } + + const factId = parseInt(customId.replace(/^(approve|reject)_fact_/, ''), 10); + const isApproval = customId.startsWith('approve_fact_'); + + if (isApproval) { + await approveFact(factId); + await interaction.update({ + content: `✅ Fact #${factId} has been approved by <@${interaction.user.id}>`, + components: [], + }); + } else { + await deleteFact(factId); + await interaction.update({ + content: `❌ Fact #${factId} has been rejected by <@${interaction.user.id}>`, + components: [], + }); + } +} + +async function handleModal(interaction: Interaction) { + if (!interaction.isModalSubmit()) return; + + const { customId } = interaction; + const modalHandlers: Record< + string, + (modalInteraction: ModalSubmitInteraction) => Promise + > = { + giveaway_prize_modal: GiveawayManager.handlers.handlePrizeSubmit, + giveaway_custom_duration: + GiveawayManager.handlers.handleCustomDurationSubmit, + giveaway_requirements_modal: + GiveawayManager.handlers.handleRequirementsSubmit, + giveaway_bonus_entries_modal: + GiveawayManager.handlers.handleBonusEntriesSubmit, + giveaway_ping_role_id_modal: + GiveawayManager.handlers.handlePingRoleIdSubmit, + giveaway_channel_id_modal: GiveawayManager.handlers.handleChannelIdSubmit, + }; + + try { + if (modalHandlers[customId]) { + await modalHandlers[customId](interaction); + } else { + console.warn('Unhandled modal submission interaction:', customId); + } + } catch (error) { + throw new Error(`Modal submission failed: ${error}`); + } +} + +async function handleSelectMenu(interaction: Interaction) { + if (!interaction.isStringSelectMenu()) return; + + const { customId } = interaction; + const selectHandlers: Record< + string, + (selectInteraction: StringSelectMenuInteraction) => Promise + > = { + giveaway_duration_select: GiveawayManager.handlers.handleDurationSelect, + giveaway_winners_select: GiveawayManager.handlers.handleWinnerSelect, + giveaway_channel_select: GiveawayManager.handlers.handleChannelSelect, + giveaway_ping_role_select: GiveawayManager.handlers.handlePingRoleSelect, + }; + + try { + if (selectHandlers[customId]) { + await selectHandlers[customId](interaction); + } else { + console.warn('Unhandled string select menu interaction:', customId); + } + } catch (error) { + throw new Error(`Select menu interaction failed: ${error}`); + } +} + +function handleInteractionError(error: unknown, interaction: Interaction) { + console.error('Interaction error:', error); + + const isUnknownInteractionError = + (error as { code?: number })?.code === 10062 || + String(error).includes('Unknown interaction'); + + if (isUnknownInteractionError) { + console.warn( + 'Interaction expired before response could be sent (code 10062)', + ); + return; + } + + const errorMessage = 'An error occurred while processing your request.'; + safelyRespond(interaction, errorMessage).catch(console.error); +} diff --git a/src/events/memberEvents.ts b/src/events/memberEvents.ts index a6ddc80..8e43b61 100644 --- a/src/events/memberEvents.ts +++ b/src/events/memberEvents.ts @@ -1,10 +1,15 @@ -import { Events, Guild, GuildMember, PartialGuildMember } from 'discord.js'; +import { + Collection, + Events, + GuildMember, + PartialGuildMember, +} from 'discord.js'; -import { updateMember, setMembers } from '../db/db.js'; -import { generateMemberBanner } from '../util/helpers.js'; -import { loadConfig } from '../util/configLoader.js'; -import { Event } from '../types/EventTypes.js'; -import logAction from '../util/logging/logAction.js'; +import { updateMember, setMembers } from '@/db/db.js'; +import { executeUnmute, generateMemberBanner } from '@/util/helpers.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { Event } from '@/types/EventTypes.js'; +import logAction from '@/util/logging/logAction.js'; export const memberJoin: Event = { name: Events.GuildMemberAdd, @@ -19,12 +24,9 @@ export const memberJoin: Event = { } try { - await setMembers([ - { - discordId: member.user.id, - discordUsername: member.user.username, - }, - ]); + const memberCollection = new Collection(); + memberCollection.set(member.user.id, member); + await setMembers(memberCollection); if (!member.user.bot) { const attachment = await generateMemberBanner({ @@ -142,6 +144,21 @@ export const memberUpdate: Event = { }); } } + + if ( + oldMember.communicationDisabledUntil !== + newMember.communicationDisabledUntil && + newMember.communicationDisabledUntil === null + ) { + await executeUnmute( + newMember.client, + guild.id, + newMember.user.id, + undefined, + guild.members.me!, + true, + ); + } } catch (error) { console.error('Error handling member update:', error); } diff --git a/src/events/messageEvents.ts b/src/events/messageEvents.ts index 8bd0f33..908de53 100644 --- a/src/events/messageEvents.ts +++ b/src/events/messageEvents.ts @@ -1,7 +1,18 @@ import { AuditLogEvent, Events, Message, PartialMessage } from 'discord.js'; -import { Event } from '../types/EventTypes.js'; -import logAction from '../util/logging/logAction.js'; +import { Event } from '@/types/EventTypes.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { + addCountingReactions, + processCountingMessage, + resetCounting, +} from '@/util/countingManager.js'; +import logAction from '@/util/logging/logAction.js'; +import { + checkAndAssignLevelRoles, + processMessage, +} from '@/util/levelingSystem.js'; +import { processLevelUpAchievements } from '@/util/achievementManager.js'; export const messageDelete: Event = { name: Events.MessageDelete, @@ -62,4 +73,93 @@ export const messageUpdate: Event = { }, }; -export default [messageDelete, messageUpdate]; +export const messageCreate: Event = { + name: Events.MessageCreate, + execute: async (message: Message) => { + try { + if (message.author.bot || !message.guild) return; + + const levelResult = await processMessage(message); + const advancementsChannelId = loadConfig().channels.advancements; + const advancementsChannel = message.guild?.channels.cache.get( + advancementsChannelId, + ); + + if (!advancementsChannel?.isTextBased()) { + console.error( + 'Advancements channel not found or is not a text channel', + ); + return; + } + + if (levelResult?.leveledUp) { + await advancementsChannel.send( + `🎉 Congratulations <@${message.author.id}>! You've leveled up to **Level ${levelResult.newLevel}**!`, + ); + + const assignedRole = await checkAndAssignLevelRoles( + message.guild, + message.author.id, + levelResult.newLevel, + ); + + await processLevelUpAchievements( + message.author.id, + levelResult.newLevel, + message.guild, + ); + + if (assignedRole) { + await advancementsChannel.send( + `<@${message.author.id}> You've earned the <@&${assignedRole}> role!`, + ); + } + } + + const countingChannelId = loadConfig().channels.counting; + const countingChannel = + message.guild?.channels.cache.get(countingChannelId); + + if (!countingChannel || message.channel.id !== countingChannelId) return; + if (!countingChannel.isTextBased()) { + console.error('Counting channel not found or is not a text channel'); + return; + } + + const result = await processCountingMessage(message); + + if (result.isValid) { + await addCountingReactions(message, result.milestoneType || 'normal'); + } else { + let errorMessage: string; + + switch (result.reason) { + case 'not_a_number': + errorMessage = `${message.author}, that's not a valid number! The count has been reset. The next number should be **1**.`; + break; + case 'too_high': + errorMessage = `${message.author}, too high! The count was **${(result?.expectedCount ?? 0) - 1}** and the next number should have been **${result.expectedCount}**. The count has been reset.`; + break; + case 'too_low': + errorMessage = `${message.author}, too low! The count was **${(result?.expectedCount ?? 0) - 1}** and the next number should have been **${result.expectedCount}**. The count has been reset.`; + break; + case 'same_user': + errorMessage = `${message.author}, you can't count twice in a row! The count has been reset. The next number should be **1**.`; + break; + default: + errorMessage = `${message.author}, something went wrong with the count. The count has been reset. The next number should be **1**.`; + } + + await resetCounting(); + + await countingChannel.send(errorMessage); + + await message.react('❌'); + } + } catch (error) { + console.error('Error handling message create: ', error); + } + }, +}; + +export default [messageCreate, messageDelete, messageUpdate]; diff --git a/src/events/reactionEvents.ts b/src/events/reactionEvents.ts new file mode 100644 index 0000000..0599409 --- /dev/null +++ b/src/events/reactionEvents.ts @@ -0,0 +1,52 @@ +import { + Events, + MessageReaction, + PartialMessageReaction, + User, + PartialUser, +} from 'discord.js'; + +import { Event } from '@/types/EventTypes.js'; +import { + decrementUserReactionCount, + incrementUserReactionCount, +} from '@/db/db.js'; +import { processReactionAchievements } from '@/util/achievementManager.js'; + +export const reactionAdd: Event = { + name: Events.MessageReactionAdd, + execute: async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + ) => { + try { + if (user.bot || !reaction.message.guild) return; + + await incrementUserReactionCount(user.id); + + await processReactionAchievements(user.id, reaction.message.guild); + } catch (error) { + console.error('Error handling reaction add:', error); + } + }, +}; + +export const reactionRemove: Event = { + name: Events.MessageReactionRemove, + execute: async ( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + ) => { + try { + if (user.bot || !reaction.message.guild) return; + + await decrementUserReactionCount(user.id); + + await processReactionAchievements(user.id, reaction.message.guild, true); + } catch (error) { + console.error('Error handling reaction remove:', error); + } + }, +}; + +export default [reactionAdd, reactionRemove]; diff --git a/src/events/ready.ts b/src/events/ready.ts index 2430295..a084068 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,15 +1,30 @@ import { Client, Events } from 'discord.js'; -import { setMembers } from '../db/db.js'; -import { loadConfig } from '../util/configLoader.js'; -import { Event } from '../types/EventTypes.js'; +import { ensureDbInitialized, setMembers } from '@/db/db.js'; +import { loadConfig } from '@/util/configLoader.js'; +import { Event } from '@/types/EventTypes.js'; +import { scheduleFactOfTheDay } from '@/util/factManager.js'; +import { scheduleGiveaways } from '@/util/giveaways/giveawayManager.js'; + +import { + ensureRedisConnection, + setDiscordClient as setRedisDiscordClient, +} from '@/db/redis.js'; +import { setDiscordClient as setDbDiscordClient } from '@/db/db.js'; +import { loadActiveBans, loadActiveMutes } from '@/util/helpers.js'; export default { name: Events.ClientReady, once: true, execute: async (client: Client) => { - const config = loadConfig(); try { + const config = loadConfig(); + setRedisDiscordClient(client); + setDbDiscordClient(client); + + await ensureDbInitialized(); + await ensureRedisConnection(); + const guild = client.guilds.cache.find( (guilds) => guilds.id === config.guildId, ); @@ -21,10 +36,16 @@ export default { const members = await guild.members.fetch(); const nonBotMembers = members.filter((m) => !m.user.bot); await setMembers(nonBotMembers); - } catch (error) { - console.error('Failed to initialize members in database:', error); - } - console.log(`Ready! Logged in as ${client.user?.tag}`); + await loadActiveBans(client, guild); + await loadActiveMutes(client, guild); + + await scheduleFactOfTheDay(client); + await scheduleGiveaways(client); + + console.log(`Ready! Logged in as ${client.user?.tag}`); + } catch (error) { + console.error('Failed to initialize the bot:', error); + } }, } as Event; diff --git a/src/events/roleEvents.ts b/src/events/roleEvents.ts index 8fc06f6..a45b471 100644 --- a/src/events/roleEvents.ts +++ b/src/events/roleEvents.ts @@ -1,7 +1,7 @@ import { AuditLogEvent, Events, Role } from 'discord.js'; -import { Event } from '../types/EventTypes.js'; -import logAction from '../util/logging/logAction.js'; +import { Event } from '@/types/EventTypes.js'; +import logAction from '@/util/logging/logAction.js'; const convertRoleProperties = (role: Role) => ({ name: role.name, diff --git a/src/structures/ExtendedClient.ts b/src/structures/ExtendedClient.ts index 4dfba26..d56e002 100644 --- a/src/structures/ExtendedClient.ts +++ b/src/structures/ExtendedClient.ts @@ -1,9 +1,12 @@ import { Client, ClientOptions, Collection } from 'discord.js'; -import { Command } from '../types/CommandTypes.js'; -import { Config } from '../types/ConfigTypes.js'; -import { deployCommands } from '../util/deployCommand.js'; -import { registerEvents } from '../util/eventLoader.js'; +import { Command } from '@/types/CommandTypes.js'; +import { Config } from '@/types/ConfigTypes.js'; +import { deployCommands, getFilesRecursively } from '@/util/deployCommand.js'; +import { registerEvents } from '@/util/eventLoader.js'; +/** + * Extended client class that extends the default Client class + */ export class ExtendedClient extends Client { public commands: Collection; private config: Config; @@ -26,20 +29,73 @@ export class ExtendedClient extends Client { private async loadModules() { try { - const commands = await deployCommands(); - if (!commands?.length) { - throw new Error('No commands found'); - } + if (process.env.SKIP_COMMAND_DEPLOY === 'true') { + console.log('Skipping command deployment (SKIP_COMMAND_DEPLOY=true)'); + const commandFiles = await this.loadCommandsWithoutDeploying(); - for (const command of commands) { - this.commands.set(command.data.name, command); - } + if (!commandFiles?.length) { + throw new Error('No commands found'); + } - await registerEvents(this); - console.log(`Loaded ${commands.length} commands and registered events`); + await registerEvents(this); + console.log( + `Loaded ${commandFiles.length} commands and registered events (without deployment)`, + ); + } else { + const commands = await deployCommands(); + if (!commands?.length) { + throw new Error('No commands found'); + } + + for (const command of commands) { + this.commands.set(command.data.name, command); + } + + await registerEvents(this); + console.log(`Loaded ${commands.length} commands and registered events`); + } } catch (error) { console.error('Error loading modules:', error); process.exit(1); } } + + /** + * Loads commands without deploying them to Discord + * @returns Array of command objects + */ + private async loadCommandsWithoutDeploying(): Promise { + try { + const path = await import('path'); + + const __dirname = path.resolve(); + const commandsPath = path.join(__dirname, 'target', 'commands'); + + const commandFiles = getFilesRecursively(commandsPath); + + const commands: Command[] = []; + for (const file of commandFiles) { + const commandModule = await import(`file://${file}`); + const command = commandModule.default; + + if ( + command instanceof Object && + 'data' in command && + 'execute' in command + ) { + commands.push(command); + this.commands.set(command.data.name, command); + } else { + console.warn( + `[WARNING] The command at ${file} is missing a required "data" or "execute" property.`, + ); + } + } + + return commands; + } catch (error) { + console.error('Error loading commands:', error); + throw error; + } + } } diff --git a/src/types/CommandTypes.ts b/src/types/CommandTypes.ts index 30d175c..9e229f1 100644 --- a/src/types/CommandTypes.ts +++ b/src/types/CommandTypes.ts @@ -1,15 +1,30 @@ import { - CommandInteraction, + ChatInputCommandInteraction, SlashCommandBuilder, SlashCommandOptionsOnlyBuilder, + SlashCommandSubcommandsOnlyBuilder, } from 'discord.js'; +/** + * Command interface for normal commands + */ export interface Command { data: Omit; - execute: (interaction: CommandInteraction) => Promise; + execute: (interaction: ChatInputCommandInteraction) => Promise; } +/** + * Command interface for options commands + */ export interface OptionsCommand { data: SlashCommandOptionsOnlyBuilder; - execute: (interaction: CommandInteraction) => Promise; + execute: (interaction: ChatInputCommandInteraction) => Promise; +} + +/** + * Command interface for subcommand commands + */ +export interface SubcommandCommand { + data: SlashCommandSubcommandsOnlyBuilder; + execute: (interaction: ChatInputCommandInteraction) => Promise; } diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index e92ae94..90418b9 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -1,14 +1,44 @@ +/** + * Config interface for the bot + */ export interface Config { token: string; clientId: string; guildId: string; - dbConnectionString: string; - redisConnectionString: string; + serverInvite: string; + database: { + dbConnectionString: string; + maxRetryAttempts: number; + retryDelay: number; + }; + redis: { + redisConnectionString: string; + retryAttempts: number; + initialRetryDelay: number; + }; channels: { welcome: string; logs: string; + counting: string; + factOfTheDay: string; + factApproval: string; + advancements: string; }; roles: { joinRoles: string[]; + levelRoles: { + level: number; + roleId: string; + }[]; + staffRoles: { + name: string; + roleId: string; + }[]; + factPingRole: string; + }; + leveling: { + xpCooldown: number; + minXpAwarded: number; + maxXpAwarded: number; }; } diff --git a/src/types/EventTypes.ts b/src/types/EventTypes.ts index f07556d..df6cf7c 100644 --- a/src/types/EventTypes.ts +++ b/src/types/EventTypes.ts @@ -1,5 +1,8 @@ import { ClientEvents } from 'discord.js'; +/** + * Event interface for events + */ export interface Event { name: K; once?: boolean; diff --git a/src/util/achievementCardGenerator.ts b/src/util/achievementCardGenerator.ts new file mode 100644 index 0000000..955fc18 --- /dev/null +++ b/src/util/achievementCardGenerator.ts @@ -0,0 +1,115 @@ +import Canvas, { GlobalFonts } from '@napi-rs/canvas'; +import { AttachmentBuilder } from 'discord.js'; +import path from 'path'; + +import * as schema from '@/db/schema.js'; +import { drawMultilineText, roundRect } from './helpers.js'; + +const __dirname = path.resolve(); + +/** + * Generates an achievement card for a user + * TODO: Make this look better + * @param achievement - The achievement to generate a card for + * @returns - The generated card as an AttachmentBuilder + */ +export async function generateAchievementCard( + achievement: schema.achievementDefinitionsTableTypes, +): Promise { + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'), + 'Manrope Bold', + ); + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'), + 'Manrope', + ); + + const width = 600; + const height = 180; + const canvas = Canvas.createCanvas(width, height); + const ctx = canvas.getContext('2d'); + + const gradient = ctx.createLinearGradient(0, 0, width, 0); + gradient.addColorStop(0, '#5865F2'); + gradient.addColorStop(1, '#EB459E'); + ctx.fillStyle = gradient; + roundRect({ ctx, x: 0, y: 0, width, height, radius: 16, fill: true }); + + ctx.lineWidth = 4; + ctx.strokeStyle = '#FFFFFF'; + roundRect({ + ctx, + x: 2, + y: 2, + width: width - 4, + height: height - 4, + radius: 16, + fill: false, + }); + + const padding = 40; + const iconSize = 72; + const iconX = padding; + const iconY = height / 2 - iconSize / 2; + + try { + const iconImage = await Canvas.loadImage( + achievement.imageUrl || + path.join(__dirname, 'assets', 'images', 'trophy.png'), + ); + + ctx.save(); + ctx.beginPath(); + ctx.arc( + iconX + iconSize / 2, + iconY + iconSize / 2, + iconSize / 2, + 0, + Math.PI * 2, + ); + ctx.clip(); + ctx.drawImage(iconImage, iconX, iconY, iconSize, iconSize); + ctx.restore(); + + ctx.beginPath(); + ctx.arc( + iconX + iconSize / 2, + iconY + iconSize / 2, + iconSize / 2 + 4, + 0, + Math.PI * 2, + ); + ctx.lineWidth = 3; + ctx.strokeStyle = '#FFFFFF'; + ctx.stroke(); + } catch (e) { + console.error('Error loading icon:', e); + } + + const textX = iconX + iconSize + 24; + const titleY = 60; + const nameY = titleY + 35; + const descY = nameY + 34; + + ctx.fillStyle = '#FFFFFF'; + + ctx.font = '22px "Manrope Bold"'; + ctx.fillText('Achievement Unlocked!', textX, titleY); + + ctx.font = '32px "Manrope Bold"'; + ctx.fillText(achievement.name, textX, nameY); + + ctx.font = '20px "Manrope"'; + drawMultilineText( + ctx, + achievement.description, + textX, + descY, + width - textX - 32, + 24, + ); + + const buffer = canvas.toBuffer('image/png'); + return new AttachmentBuilder(buffer, { name: 'achievement.png' }); +} diff --git a/src/util/achievementManager.ts b/src/util/achievementManager.ts new file mode 100644 index 0000000..adb78d9 --- /dev/null +++ b/src/util/achievementManager.ts @@ -0,0 +1,303 @@ +import { + Message, + Client, + EmbedBuilder, + GuildMember, + TextChannel, + Guild, +} from 'discord.js'; + +import { + addXpToUser, + awardAchievement, + getAllAchievements, + getUserAchievements, + getUserLevel, + getUserReactionCount, + updateAchievementProgress, +} from '@/db/db.js'; +import * as schema from '@/db/schema.js'; +import { loadConfig } from './configLoader.js'; +import { generateAchievementCard } from './achievementCardGenerator.js'; + +/** + * Check and process achievements for a user based on a message + * @param message - The message that triggered the check + */ +export async function processMessageAchievements( + message: Message, +): Promise { + if (message.author.bot) return; + + const userData = await getUserLevel(message.author.id); + const allAchievements = await getAllAchievements(); + + const messageAchievements = allAchievements.filter( + (a) => a.requirementType === 'message_count', + ); + + for (const achievement of messageAchievements) { + const progress = Math.min( + 100, + (userData.messagesSent / achievement.threshold) * 100, + ); + + if (progress >= 100) { + const userAchievements = await getUserAchievements(message.author.id); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement( + message.author.id, + achievement.id, + ); + if (awarded) { + await announceAchievement( + message.guild!, + message.author.id, + achievement, + ); + } + } + } else { + await updateAchievementProgress( + message.author.id, + achievement.id, + progress, + ); + } + } + + const levelAchievements = allAchievements.filter( + (a) => a.requirementType === 'level', + ); + + for (const achievement of levelAchievements) { + const progress = Math.min( + 100, + (userData.level / achievement.threshold) * 100, + ); + + if (progress >= 100) { + const userAchievements = await getUserAchievements(message.author.id); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement( + message.author.id, + achievement.id, + ); + if (awarded) { + await announceAchievement( + message.guild!, + message.author.id, + achievement, + ); + } + } + } else { + await updateAchievementProgress( + message.author.id, + achievement.id, + progress, + ); + } + } +} + +/** + * Check achievements for level-ups + * @param memberId - Member ID who leveled up + * @param newLevel - New level value + * @guild - Guild instance + */ +export async function processLevelUpAchievements( + memberId: string, + newLevel: number, + guild: Guild, +): Promise { + const allAchievements = await getAllAchievements(); + + const levelAchievements = allAchievements.filter( + (a) => a.requirementType === 'level', + ); + + for (const achievement of levelAchievements) { + const progress = Math.min(100, (newLevel / achievement.threshold) * 100); + + if (progress >= 100) { + const userAchievements = await getUserAchievements(memberId); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement(memberId, achievement.id); + if (awarded) { + await announceAchievement(guild, memberId, achievement); + } + } + } else { + await updateAchievementProgress(memberId, achievement.id, progress); + } + } +} + +/** + * Process achievements for command usage + * @param userId - User ID who used the command + * @param commandName - Name of the command + * @param client - Guild instance + */ +export async function processCommandAchievements( + userId: string, + commandName: string, + guild: Guild, +): Promise { + const allAchievements = await getAllAchievements(); + + const commandAchievements = allAchievements.filter( + (a) => + a.requirementType === 'command_usage' && + a.requirement && + (a.requirement as any).command === commandName, + ); + + for (const achievement of commandAchievements) { + const userAchievements = await getUserAchievements(userId); + const existingAchievement = userAchievements.find( + (a) => a.achievementId === achievement.id && a.earnedAt !== null, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement(userId, achievement.id); + if (awarded) { + await announceAchievement(guild, userId, achievement); + } + } + } +} + +/** + * Process achievements for reaction events (add or remove) + * @param userId - User ID who added/removed the reaction + * @param guild - Guild instance + * @param isRemoval - Whether this is a reaction removal (true) or addition (false) + */ +export async function processReactionAchievements( + userId: string, + guild: Guild, + isRemoval: boolean = false, +): Promise { + try { + const member = await guild.members.fetch(userId); + if (member.user.bot) return; + + const allAchievements = await getAllAchievements(); + + const reactionAchievements = allAchievements.filter( + (a) => a.requirementType === 'reactions', + ); + + if (reactionAchievements.length === 0) return; + + const reactionCount = await getUserReactionCount(userId); + + for (const achievement of reactionAchievements) { + const progress = Math.max( + 0, + Math.min(100, (reactionCount / achievement.threshold) * 100), + ); + + if (progress >= 100 && !isRemoval) { + const userAchievements = await getUserAchievements(userId); + const existingAchievement = userAchievements.find( + (a) => + a.achievementId === achievement.id && + a.earnedAt !== null && + a.earnedAt !== undefined && + new Date(a.earnedAt).getTime() > 0, + ); + + if (!existingAchievement) { + const awarded = await awardAchievement(userId, achievement.id); + if (awarded) { + await announceAchievement(guild, userId, achievement); + } + } + } + + await updateAchievementProgress(userId, achievement.id, progress); + } + } catch (error) { + console.error('Error processing reaction achievements:', error); + } +} + +/** + * Announce a newly earned achievement + * @param guild - Guild instance + * @param userId - ID of the user who earned the achievement + * @param achievement - Achievement definition + */ +export async function announceAchievement( + guild: Guild, + userId: string, + achievement: schema.achievementDefinitionsTableTypes, +): Promise { + try { + const config = loadConfig(); + + if (!guild) { + console.error(`Guild ${guild} not found`); + return; + } + + const member = await guild.members.fetch(userId); + if (!member) { + console.warn(`Member ${userId} not found in guild`); + return; + } + + const achievementCard = await generateAchievementCard(achievement); + + const embed = new EmbedBuilder() + .setColor(0xffd700) + .setDescription( + `**${member.user.username}** just unlocked the achievement: **${achievement.name}**! 🎉`, + ) + .setImage('attachment://achievement.png') + .setTimestamp(); + + const advChannel = guild.channels.cache.get(config.channels.advancements); + if (advChannel?.isTextBased()) { + await (advChannel as TextChannel).send({ + content: `Congratulations <@${userId}>!`, + embeds: [embed], + files: [achievementCard], + }); + } + + if (achievement.rewardType === 'xp' && achievement.rewardValue) { + const xpAmount = parseInt(achievement.rewardValue); + if (!isNaN(xpAmount)) { + await addXpToUser(userId, xpAmount); + } + } else if (achievement.rewardType === 'role' && achievement.rewardValue) { + try { + await member.roles.add(achievement.rewardValue); + } catch (err) { + console.error( + `Failed to add role ${achievement.rewardValue} to user ${userId}`, + err, + ); + } + } + } catch (error) { + console.error('Error announcing achievement:', error); + } +} diff --git a/src/util/configLoader.ts b/src/util/configLoader.ts index 497e5a0..9c61352 100644 --- a/src/util/configLoader.ts +++ b/src/util/configLoader.ts @@ -1,7 +1,12 @@ -import { Config } from '../types/ConfigTypes.js'; import fs from 'node:fs'; import path from 'node:path'; +import { Config } from '@/types/ConfigTypes.js'; + +/** + * Loads the config file from the root directory + * @returns - The loaded config object + */ export function loadConfig(): Config { try { const configPath = path.join(process.cwd(), './config.json'); diff --git a/src/util/countingManager.ts b/src/util/countingManager.ts new file mode 100644 index 0000000..b1b37d9 --- /dev/null +++ b/src/util/countingManager.ts @@ -0,0 +1,191 @@ +import { Message } from 'discord.js'; + +import { getJson, setJson } from '@/db/redis.js'; + +interface CountingData { + currentCount: number; + lastUserId: string | null; + highestCount: number; + totalCorrect: number; +} + +const MILESTONE_REACTIONS = { + normal: '✅', + multiples25: '✨', + multiples50: '⭐', + multiples100: '🎉', +}; + +/** + * Initializes the counting data if it doesn't exist + * @returns - The initialized counting data + */ +export async function initializeCountingData(): Promise { + const exists = await getJson('counting'); + if (exists) return exists; + + const initialData: CountingData = { + currentCount: 0, + lastUserId: null, + highestCount: 0, + totalCorrect: 0, + }; + + await setJson('counting', initialData); + return initialData; +} + +/** + * Gets the current counting data + * @returns - The current counting data + */ +export async function getCountingData(): Promise { + const data = await getJson('counting'); + if (!data) { + return initializeCountingData(); + } + return data; +} + +/** + * Updates the counting data with new data + * @param data - The data to update the counting data with + */ +export async function updateCountingData( + data: Partial, +): Promise { + const currentData = await getCountingData(); + const updatedData = { ...currentData, ...data }; + await setJson('counting', updatedData); +} + +/** + * Resets the counting data to the initial state + * @returns - The current count + */ +export async function resetCounting(): Promise { + await updateCountingData({ + currentCount: 0, + lastUserId: null, + }); + return; +} + +/** + * Processes a counting message to determine if it is valid + * @param message - The message to process + * @returns - An object with information about the message + */ +export async function processCountingMessage(message: Message): Promise<{ + isValid: boolean; + expectedCount?: number; + isMilestone?: boolean; + milestoneType?: keyof typeof MILESTONE_REACTIONS; + reason?: string; +}> { + try { + const countingData = await getCountingData(); + + const content = message.content.trim(); + const count = Number(content); + + if (isNaN(count) || !Number.isInteger(count)) { + return { + isValid: false, + expectedCount: countingData.currentCount + 1, + reason: 'not_a_number', + }; + } + + const expectedCount = countingData.currentCount + 1; + if (count !== expectedCount) { + return { + isValid: false, + expectedCount, + reason: count > expectedCount ? 'too_high' : 'too_low', + }; + } + + if (countingData.lastUserId === message.author.id) { + return { isValid: false, expectedCount, reason: 'same_user' }; + } + + const newCount = countingData.currentCount + 1; + const newHighestCount = Math.max(newCount, countingData.highestCount); + + await updateCountingData({ + currentCount: newCount, + lastUserId: message.author.id, + highestCount: newHighestCount, + totalCorrect: countingData.totalCorrect + 1, + }); + + let isMilestone = false; + let milestoneType: keyof typeof MILESTONE_REACTIONS = 'normal'; + + if (newCount % 100 === 0) { + isMilestone = true; + milestoneType = 'multiples100'; + } else if (newCount % 50 === 0) { + isMilestone = true; + milestoneType = 'multiples50'; + } else if (newCount % 25 === 0) { + isMilestone = true; + milestoneType = 'multiples25'; + } + + return { + isValid: true, + expectedCount: newCount + 1, + isMilestone, + milestoneType, + }; + } catch (error) { + console.error('Error processing counting message:', error); + return { isValid: false, reason: 'error' }; + } +} + +/** + * Adds counting reactions to a message based on the milestone type + * @param message - The message to add counting reactions to + * @param milestoneType - The type of milestone to add reactions for + */ +export async function addCountingReactions( + message: Message, + milestoneType: keyof typeof MILESTONE_REACTIONS, +): Promise { + try { + await message.react(MILESTONE_REACTIONS[milestoneType]); + + if (milestoneType === 'multiples100') { + await message.react('💯'); + } + } catch (error) { + console.error('Error adding counting reactions:', error); + } +} + +/** + * Gets the current counting status + * @returns - A string with the current counting status + */ +export async function getCountingStatus(): Promise { + const data = await getCountingData(); + return `Current count: ${data.currentCount}\nHighest count ever: ${data.highestCount}\nTotal correct counts: ${data.totalCorrect}`; +} + +/** + * Sets the current count to a specific number + * @param count - The number to set as the current count + */ +export async function setCount(count: number): Promise { + if (!Number.isInteger(count) || count < 0) { + throw new Error('Count must be a non-negative integer.'); + } + + await updateCountingData({ + currentCount: count, + lastUserId: null, + }); +} diff --git a/src/util/deployCommand.ts b/src/util/deployCommand.ts index 9ce4580..31d9f88 100644 --- a/src/util/deployCommand.ts +++ b/src/util/deployCommand.ts @@ -1,6 +1,7 @@ -import { REST, Routes } from 'discord.js'; import fs from 'fs'; import path from 'path'; +import { REST, Routes } from 'discord.js'; + import { loadConfig } from './configLoader.js'; const config = loadConfig(); @@ -11,7 +12,12 @@ const commandsPath = path.join(__dirname, 'target', 'commands'); const rest = new REST({ version: '10' }).setToken(token); -const getFilesRecursively = (directory: string): string[] => { +/** + * Gets all files in the command directory and its subdirectories + * @param directory - The directory to get files from + * @returns - An array of file paths + */ +export const getFilesRecursively = (directory: string): string[] => { const files: string[] = []; const filesInDirectory = fs.readdirSync(directory); @@ -30,15 +36,21 @@ const getFilesRecursively = (directory: string): string[] => { const commandFiles = getFilesRecursively(commandsPath); +/** + * Registers all commands in the command directory with the Discord API + * @returns - An array of valid command objects + */ export const deployCommands = async () => { try { console.log( `Started refreshing ${commandFiles.length} application (/) commands...`, ); - const existingCommands = (await rest.get( - Routes.applicationGuildCommands(clientId, guildId), - )) as any[]; + console.log('Undeploying all existing commands...'); + await rest.put(Routes.applicationGuildCommands(clientId, guildId), { + body: [], + }); + console.log('Successfully undeployed all commands'); const commands = commandFiles.map(async (file) => { const commandModule = await import(`file://${file}`); @@ -64,18 +76,6 @@ export const deployCommands = async () => { const apiCommands = validCommands.map((command) => command.data.toJSON()); - const commandsToRemove = existingCommands.filter( - (existingCmd) => - !apiCommands.some((newCmd) => newCmd.name === existingCmd.name), - ); - - for (const cmdToRemove of commandsToRemove) { - await rest.delete( - Routes.applicationGuildCommand(clientId, guildId, cmdToRemove.id), - ); - console.log(`Removed command: ${cmdToRemove.name}`); - } - const data: any = await rest.put( Routes.applicationGuildCommands(clientId, guildId), { body: apiCommands }, diff --git a/src/util/eventLoader.ts b/src/util/eventLoader.ts index 855f296..bd7a721 100644 --- a/src/util/eventLoader.ts +++ b/src/util/eventLoader.ts @@ -1,12 +1,15 @@ import { Client } from 'discord.js'; import { readdirSync } from 'fs'; -import { join } from 'path'; +import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; -import { dirname } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +/** + * Registers all event handlers in the events directory + * @param client - The Discord client + */ export async function registerEvents(client: Client): Promise { try { const eventsPath = join(__dirname, '..', 'events'); diff --git a/src/util/factManager.ts b/src/util/factManager.ts new file mode 100644 index 0000000..56aa654 --- /dev/null +++ b/src/util/factManager.ts @@ -0,0 +1,89 @@ +import { EmbedBuilder, Client } from 'discord.js'; + +import { getRandomUnusedFact, markFactAsUsed } from '@/db/db.js'; +import { loadConfig } from './configLoader.js'; + +let isFactScheduled = false; + +/** + * Schedule the fact of the day to be posted daily + * @param client - The Discord client + */ +export async function scheduleFactOfTheDay(client: Client): Promise { + if (isFactScheduled) { + console.log( + 'Fact of the day already scheduled, skipping duplicate schedule', + ); + return; + } + + try { + isFactScheduled = true; + const now = new Date(); + const tomorrow = new Date(); + tomorrow.setDate(now.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + const timeUntilMidnight = tomorrow.getTime() - now.getTime(); + + setTimeout(() => { + postFactOfTheDay(client); + isFactScheduled = false; + scheduleFactOfTheDay(client); + }, timeUntilMidnight); + + console.log( + `Next fact of the day scheduled in ${Math.floor(timeUntilMidnight / 1000 / 60)} minutes`, + ); + } catch (error) { + console.error('Error scheduling fact of the day:', error); + isFactScheduled = false; + setTimeout(() => scheduleFactOfTheDay(client), 60 * 60 * 1000); + } +} + +/** + * Post the fact of the day to the configured channel + * @param client - The Discord client + */ +export async function postFactOfTheDay(client: Client): Promise { + try { + const config = loadConfig(); + const guild = client.guilds.cache.get(config.guildId); + + if (!guild) { + console.error('Guild not found'); + return; + } + + const factChannel = guild.channels.cache.get(config.channels.factOfTheDay); + if (!factChannel?.isTextBased()) { + console.error('Fact channel not found or is not a text channel'); + return; + } + + const fact = await getRandomUnusedFact(); + if (!fact) { + console.error('No facts available'); + return; + } + + const embed = new EmbedBuilder() + .setTitle('🌟 Fact of the Day 🌟') + .setDescription(fact.content) + .setColor(0xffaa00) + .setTimestamp(); + + if (fact.source) { + embed.setFooter({ text: `Source: ${fact.source}` }); + } + + await factChannel.send({ + content: `<@&${config.roles.factPingRole}>`, + embeds: [embed], + }); + await markFactAsUsed(fact.id!); + } catch (error) { + console.error('Error posting fact of the day:', error); + } +} diff --git a/src/util/giveaways/builder.ts b/src/util/giveaways/builder.ts new file mode 100644 index 0000000..8be6a73 --- /dev/null +++ b/src/util/giveaways/builder.ts @@ -0,0 +1,375 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ChatInputCommandInteraction, + EmbedBuilder, +} from 'discord.js'; + +import { GiveawaySession } from './types.js'; +import { DEFAULT_REQUIRE_ALL, DEFAULT_WINNER_COUNT } from './constants.js'; +import { getSession, saveSession } from './utils.js'; + +/** + * Handles the start of the giveaway builder. + * @param interaction The interaction object from the command or button click. + */ +export async function startGiveawayBuilder( + interaction: ChatInputCommandInteraction | ButtonInteraction, +): Promise { + await interaction.deferReply({ flags: ['Ephemeral'] }); + + const session: GiveawaySession = { + step: 1, + winnerCount: DEFAULT_WINNER_COUNT, + requirements: { + requireAll: DEFAULT_REQUIRE_ALL, + }, + }; + + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the display of the current step in the giveaway builder. + * @param interaction The interaction object from the command or button click. + * @param session The current giveaway session. + */ +export async function showBuilderStep( + interaction: any, + session: GiveawaySession, +): Promise { + if (!interaction.isCommand() && interaction.responded) { + return; + } + + try { + let embed: EmbedBuilder; + const components: ActionRowBuilder[] = []; + + switch (session.step) { + case 1: + embed = createStep1Embed(session); + components.push(createStep1Buttons(session)); + break; + case 2: + embed = createStep2Embed(session); + components.push(...createStep2Buttons(session)); + break; + case 3: + embed = createStep3Embed(session); + components.push(...createStep3Buttons(session)); + break; + case 4: + embed = createStep4Embed(session); + components.push(...createStep4Buttons()); + break; + case 5: + embed = createStep5Embed(session); + components.push(...createStep5Buttons()); + break; + default: + embed = new EmbedBuilder() + .setTitle('🎉 Giveaway Creation') + .setDescription('Setting up your giveaway...') + .setColor(0x3498db); + } + + if (interaction.replied || interaction.deferred) { + await interaction.editReply({ embeds: [embed], components }); + } else { + await interaction.update({ embeds: [embed], components }); + } + } catch (error) { + console.error('Error in showBuilderStep:', error); + if (!interaction.replied) { + try { + await interaction.reply({ + content: 'There was an error updating the giveaway builder.', + flags: ['Ephemeral'], + }); + } catch (replyError) { + console.error('Failed to send error reply:', replyError); + } + } + } +} + +/** + * Handles the next step in the giveaway builder. + * @param interaction The interaction object from the button click. + */ +export async function nextBuilderStep( + interaction: ButtonInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + if (session.step === 1) { + if (!session.prize || !session.endTime) { + await interaction.reply({ + content: 'Please set both prize and duration before continuing.', + flags: ['Ephemeral'], + }); + return; + } + + if (!(session.endTime instanceof Date)) { + await interaction.reply({ + content: 'Invalid duration setting. Please set the duration again.', + flags: ['Ephemeral'], + }); + return; + } + } + + session.step = Math.min(session.step + 1, 5); + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the previous step in the giveaway builder. + * @param interaction The interaction object from the button click. + */ +export async function previousBuilderStep( + interaction: ButtonInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.step = Math.max(session.step - 1, 1); + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +function createStep1Embed(session: GiveawaySession): EmbedBuilder { + const endTimeValue = + session.endTime instanceof Date + ? `${session.duration} (ends )` + : 'Not set'; + + return new EmbedBuilder() + .setTitle(' Giveaway Creation - Step 1/5') + .setDescription('Set the basic details for your giveaway.') + .setColor(0x3498db) + .addFields([ + { name: 'Prize', value: session.prize || 'Not set', inline: true }, + { name: 'Duration', value: endTimeValue, inline: true }, + { name: 'Winners', value: session.winnerCount.toString(), inline: true }, + ]); +} + +function createStep1Buttons( + session: GiveawaySession, +): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_prize') + .setLabel('Set Prize') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_set_duration') + .setLabel('Set Duration') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_set_winners') + .setLabel('Set Winners') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success) + .setDisabled(!session.prize || !session.endTime), + ); +} + +function createStep2Embed(session: GiveawaySession): EmbedBuilder { + const requirementsList = []; + if (session.requirements?.level) { + requirementsList.push(`• Level ${session.requirements.level}+`); + } + if (session.requirements?.roleId) { + requirementsList.push(`• Role <@&${session.requirements.roleId}>`); + } + if (session.requirements?.messageCount) { + requirementsList.push(`• ${session.requirements.messageCount}+ messages`); + } + + const requirementsText = requirementsList.length + ? `${session.requirements.requireAll ? 'ALL requirements must be met' : 'ANY ONE requirement must be met'}\n${requirementsList.join('\n')}` + : 'No requirements set'; + + return new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 2/5') + .setDescription('Set entry requirements for your giveaway (optional).') + .setColor(0x3498db) + .addFields([ + { name: 'Prize', value: session.prize || 'Not set' }, + { name: 'Requirements', value: requirementsText }, + ]); +} + +function createStep2Buttons( + session: GiveawaySession, +): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_requirements') + .setLabel('Set Requirements') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('giveaway_toggle_logic') + .setLabel( + session.requirements.requireAll ? 'Require ANY' : 'Require ALL', + ) + .setStyle(ButtonStyle.Secondary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success), + ), + ]; +} + +function createStep3Embed(session: GiveawaySession): EmbedBuilder { + const embed = new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 3/5') + .setDescription('Select Giveaway Channel (optional).') + .setColor(0x3498db) + .addFields([ + { + name: 'Channel', + value: session.channelId + ? `<#${session.channelId}>` + : 'Current Channel', + }, + ]); + + return embed; +} + +function createStep3Buttons( + session: GiveawaySession, +): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_channel') + .setLabel(session.channelId ? 'Change Channel' : 'Set Channel') + .setStyle(ButtonStyle.Primary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success), + ), + ]; +} + +function createStep4Embed(session: GiveawaySession): EmbedBuilder { + const bonusEntries = session.bonusEntries || {}; + + const rolesText = + bonusEntries.roles?.map((r) => `<@&${r.id}>: +${r.entries}`).join('\n') || + 'None'; + const levelsText = + bonusEntries.levels + ?.map((l) => `Level ${l.threshold}+: +${l.entries}`) + .join('\n') || 'None'; + const messagesText = + bonusEntries.messages + ?.map((m) => `${m.threshold}+ messages: +${m.entries}`) + .join('\n') || 'None'; + + return new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 4/5') + .setDescription('Configure bonus entries for your giveaway.') + .setColor(0x3498db) + .addFields([ + { name: 'Role Bonuses', value: rolesText, inline: true }, + { name: 'Level Bonuses', value: levelsText, inline: true }, + { name: 'Message Bonuses', value: messagesText, inline: true }, + ]); +} + +function createStep4Buttons(): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_bonus_entries') + .setLabel('Set Bonus Entries') + .setStyle(ButtonStyle.Primary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_next') + .setLabel('Next Step') + .setStyle(ButtonStyle.Success), + ), + ]; +} + +function createStep5Embed(session: GiveawaySession): EmbedBuilder { + return new EmbedBuilder() + .setTitle('🎉 Giveaway Creation - Step 5/5') + .setDescription('Finalize your giveaway settings.') + .setColor(0x3498db) + .addFields([ + { + name: 'Role to Ping', + value: session.pingRoleId ? `<@&${session.pingRoleId}>` : 'None', + }, + ]); +} + +function createStep5Buttons(): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_set_ping_role') + .setLabel('Set Ping Role') + .setStyle(ButtonStyle.Primary), + ), + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('giveaway_previous') + .setLabel('Previous Step') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('giveaway_publish') + .setLabel('Create Giveaway') + .setStyle(ButtonStyle.Success), + ), + ]; +} diff --git a/src/util/giveaways/constants.ts b/src/util/giveaways/constants.ts new file mode 100644 index 0000000..bbb521c --- /dev/null +++ b/src/util/giveaways/constants.ts @@ -0,0 +1,4 @@ +export const SESSION_TIMEOUT = 1800; +export const SESSION_PREFIX = 'giveaway:session:'; +export const DEFAULT_WINNER_COUNT = 1; +export const DEFAULT_REQUIRE_ALL = true; diff --git a/src/util/giveaways/dropdowns.ts b/src/util/giveaways/dropdowns.ts new file mode 100644 index 0000000..d01cadb --- /dev/null +++ b/src/util/giveaways/dropdowns.ts @@ -0,0 +1,149 @@ +import { + ActionRowBuilder, + ButtonInteraction, + StringSelectMenuBuilder, +} from 'discord.js'; + +/** + * Show a select menu for pinging a role. + * @param interaction The button interaction that triggered this function. + */ +export async function showPingRoleSelect( + interaction: ButtonInteraction, +): Promise { + const roles = interaction.guild?.roles.cache + .filter((role) => role.id !== interaction.guild?.id) + .sort((a, b) => a.position - b.position) + .map((role) => ({ + label: role.name.substring(0, 25), + value: role.id, + description: `@${role.name}`, + })); + + if (!roles?.length) { + await interaction.reply({ + content: 'No roles found in this server.', + flags: ['Ephemeral'], + }); + return; + } + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_ping_role_select') + .setPlaceholder('Select a role to ping (optional)') + .addOptions([...roles.slice(0, 25)]), + ); + + await interaction.reply({ + content: 'Select a role to ping when the giveaway starts:', + components: [row], + flags: ['Ephemeral'], + }); +} + +/** + * Show a select menu for choosing a duration for the giveaway. + * @param interaction The button interaction that triggered this function. + */ +export async function showDurationSelect( + interaction: ButtonInteraction, +): Promise { + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_duration_select') + .setPlaceholder('Select duration') + .addOptions([ + { label: '1 hour', value: '1h', description: 'End giveaway in 1 hour' }, + { + label: '6 hours', + value: '6h', + description: 'End giveaway in 6 hours', + }, + { + label: '12 hours', + value: '12h', + description: 'End giveaway in 12 hours', + }, + { label: '1 day', value: '1d', description: 'End giveaway in 1 day' }, + { label: '3 days', value: '3d', description: 'End giveaway in 3 days' }, + { label: '7 days', value: '7d', description: 'End giveaway in 7 days' }, + { + label: 'Custom', + value: 'custom', + description: 'Set a custom duration', + }, + ]), + ); + + await interaction.reply({ + content: 'Select the duration for your giveaway:', + components: [row], + flags: ['Ephemeral'], + }); +} + +/** + * Show a select menu for choosing the number of winners for the giveaway. + * @param interaction The button interaction that triggered this function. + */ +export async function showWinnerSelect( + interaction: ButtonInteraction, +): Promise { + const options = [1, 2, 3, 5, 10].map((num) => ({ + label: `${num} winner${num > 1 ? 's' : ''}`, + value: num.toString(), + description: `Select ${num} winner${num > 1 ? 's' : ''} for the giveaway`, + })); + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_winners_select') + .setPlaceholder('Select number of winners') + .addOptions(options), + ); + + await interaction.reply({ + content: 'How many winners should this giveaway have?', + components: [row], + flags: ['Ephemeral'], + }); +} + +/** + * Show a select menu for choosing a channel for the giveaway. + * @param interaction The button interaction that triggered this function. + */ +export async function showChannelSelect( + interaction: ButtonInteraction, +): Promise { + const channels = interaction.guild?.channels.cache + .filter((channel) => channel.isTextBased()) + .map((channel) => ({ + label: channel.name.substring(0, 25), + value: channel.id, + description: `#${channel.name}`, + })) + .slice(0, 25); + + if (!channels?.length) { + await interaction.reply({ + content: 'No suitable text channels found in this server.', + flags: ['Ephemeral'], + }); + return; + } + + const row = new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('giveaway_channel_select') + .setPlaceholder('Select a channel') + .addOptions(channels), + ); + + await interaction.reply({ + content: 'Select the channel to host the giveaway in:', + components: [row], + flags: ['Ephemeral'], + }); +} diff --git a/src/util/giveaways/giveawayManager.ts b/src/util/giveaways/giveawayManager.ts new file mode 100644 index 0000000..17ab6b4 --- /dev/null +++ b/src/util/giveaways/giveawayManager.ts @@ -0,0 +1,362 @@ +import { + ButtonInteraction, + Client, + EmbedBuilder, + TextChannel, +} from 'discord.js'; + +import { createGiveaway, endGiveaway, getActiveGiveaways } from '@/db/db.js'; +import { GiveawayEmbedParams } from './types.js'; +import { + createGiveawayButtons, + deleteSession, + formatWinnerMentions, + getSession, + toggleRequirementLogic, + selectGiveawayWinners, +} from './utils.js'; +import { loadConfig } from '../configLoader.js'; +import * as builder from './builder.js'; +import * as dropdowns from './dropdowns.js'; +import * as handlers from './handlers.js'; +import * as modals from './modals.js'; + +/** + * Creates a Discord embed for a giveaway based on the provided parameters. + * Handles both active and ended giveaway states. + * + * @param params - The parameters needed to build the giveaway embed. + * @returns A configured EmbedBuilder instance for the giveaway. + */ +export function createGiveawayEmbed(params: GiveawayEmbedParams): EmbedBuilder { + const { + id, + prize, + endTime, + winnerCount = 1, + hostId, + participantCount = 0, + winnersIds, + isEnded = false, + footerText, + requiredLevel, + requiredRoleId, + requiredMessageCount, + requireAllCriteria = true, + bonusEntries, + } = params; + + const embed = new EmbedBuilder() + .setTitle(isEnded ? '🎉 Giveaway Ended 🎉' : '🎉 Giveaway 🎉') + .setDescription( + `**Prize**: ${prize}${id ? `\n**Giveaway ID**: ${id}` : ''}`, + ) + .setColor(isEnded ? 0xff0000 : 0x00ff00); + + if (isEnded) { + embed.addFields( + { name: 'Winner(s)', value: formatWinnerMentions(winnersIds) }, + { name: 'Hosted by', value: `<@${hostId}>` }, + ); + embed.setFooter({ text: footerText || 'Ended at' }); + embed.setTimestamp(); + } else { + embed.addFields( + { name: 'Winner(s)', value: winnerCount.toString(), inline: true }, + { name: 'Entries', value: participantCount.toString(), inline: true }, + { + name: 'Ends at', + value: endTime + ? `` + : 'Soon', + inline: true, + }, + { name: 'Hosted by', value: `<@${hostId}>` }, + ); + + const requirements: string[] = []; + if (requiredLevel) requirements.push(`• Level ${requiredLevel}+ required`); + if (requiredRoleId) { + requirements.push(`• <@&${requiredRoleId}> role required`); + } + if (requiredMessageCount) { + requirements.push(`• ${requiredMessageCount}+ messages required`); + } + + if (requirements.length) { + embed.addFields({ + name: `📋 Entry Requirements (${requireAllCriteria ? 'ALL required' : 'ANY one required'})`, + value: requirements.join('\n'), + }); + } + + const bonusDetails: string[] = []; + bonusEntries?.roles?.forEach((r) => + bonusDetails.push(`• <@&${r.id}>: +${r.entries} entries`), + ); + bonusEntries?.levels?.forEach((l) => + bonusDetails.push(`• Level ${l.threshold}+: +${l.entries} entries`), + ); + bonusEntries?.messages?.forEach((m) => + bonusDetails.push(`• ${m.threshold}+ messages: +${m.entries} entries`), + ); + + if (bonusDetails.length) { + embed.addFields({ + name: '✨ Bonus Entries', + value: bonusDetails.join('\n'), + }); + } + + embed.setFooter({ text: 'End time' }); + if (endTime) embed.setTimestamp(endTime); + } + + return embed; +} + +/** + * Processes a giveaway that has ended. Fetches the ended giveaway data, + * updates the original message, announces the winners (if any), and handles errors. + * + * @param client - The Discord Client instance. + * @param messageId - The message ID of the giveaway to process. + */ +export async function processEndedGiveaway( + client: Client, + messageId: string, +): Promise { + try { + const endedGiveaway = await endGiveaway(messageId); + if (!endedGiveaway) { + console.warn( + `Attempted to process non-existent or already ended giveaway: ${messageId}`, + ); + return; + } + + const config = loadConfig(); + const guild = client.guilds.cache.get(config.guildId); + if (!guild) { + console.error(`Guild ${config.guildId} not found.`); + return; + } + + const channel = guild.channels.cache.get(endedGiveaway.channelId); + if (!channel?.isTextBased()) { + console.warn( + `Giveaway channel ${endedGiveaway.channelId} not found or not text-based.`, + ); + return; + } + + try { + const giveawayMessage = await channel.messages.fetch(messageId); + if (!giveawayMessage) { + console.warn( + `Giveaway message ${messageId} not found in channel ${channel.id}.`, + ); + return; + } + + await giveawayMessage.edit({ + embeds: [ + createGiveawayEmbed({ + id: endedGiveaway.id, + prize: endedGiveaway.prize, + hostId: endedGiveaway.hostId, + winnersIds: endedGiveaway.winnersIds ?? [], + isEnded: true, + }), + ], + components: [], + }); + + if (endedGiveaway.winnersIds?.length) { + const winnerMentions = formatWinnerMentions(endedGiveaway.winnersIds); + await channel.send({ + content: `Congratulations ${winnerMentions}! You won **${endedGiveaway.prize}**!`, + allowedMentions: { users: endedGiveaway.winnersIds }, + }); + } else { + await channel.send( + `No one entered the giveaway for **${endedGiveaway.prize}**!`, + ); + } + } catch (error) { + console.error(`Error updating giveaway message ${messageId}:`, error); + } + } catch (error) { + console.error(`Error processing ended giveaway ${messageId}:`, error); + } +} + +/** + * Schedules all active giveaways fetched from the database to end at their designated time. + * If a giveaway's end time is already past, it processes it immediately. + * This function should be called on bot startup. + * + * @param client - The Discord Client instance. + */ +export async function scheduleGiveaways(client: Client): Promise { + try { + const activeGiveaways = await getActiveGiveaways(); + console.log( + `Found ${activeGiveaways.length} active giveaways to schedule.`, + ); + + for (const giveaway of activeGiveaways) { + const endTime = giveaway.endAt.getTime(); + const now = Date.now(); + const timeLeft = endTime - now; + + if (timeLeft <= 0) { + console.log( + `Giveaway ID ${giveaway.id} end time has passed. Processing now.`, + ); + await processEndedGiveaway(client, giveaway.messageId); + } else { + console.log( + `Scheduling giveaway ID ${giveaway.id} to end in ${Math.floor(timeLeft / 1000)} seconds.`, + ); + setTimeout(() => { + processEndedGiveaway(client, giveaway.messageId); + }, timeLeft); + } + } + console.log('Finished scheduling active giveaways.'); + } catch (error) { + console.error('Error scheduling giveaways:', error); + } +} + +/** + * Publishes a giveaway based on the session data associated with the interacting user. + * Sends the giveaway message to the designated channel, saves it to the database, + * schedules its end, and cleans up the user's session. + * + * @param interaction - The button interaction triggering the publish action. + */ +export async function publishGiveaway( + interaction: ButtonInteraction, +): Promise { + await interaction.deferUpdate(); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.followUp({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + if (!session.prize || !session.endTime) { + await interaction.followUp({ + content: 'Missing required information. Please complete all steps.', + flags: ['Ephemeral'], + }); + return; + } + + try { + const channelId = session.channelId || interaction.channelId; + const channel = await interaction.guild?.channels.fetch(channelId); + if (!channel?.isTextBased()) { + await interaction.followUp({ + content: 'Invalid channel selected.', + flags: ['Ephemeral'], + }); + return; + } + + const pingContent = session.pingRoleId ? `<@&${session.pingRoleId}>` : ''; + + const initialEmbed = createGiveawayEmbed({ + prize: session.prize, + endTime: session.endTime, + winnerCount: session.winnerCount, + hostId: interaction.user.id, + participantCount: 0, + requiredLevel: session.requirements?.level, + requiredRoleId: session.requirements?.roleId, + requiredMessageCount: session.requirements?.messageCount, + requireAllCriteria: session.requirements.requireAll, + bonusEntries: session.bonusEntries, + }); + + const giveawayMessage = await (channel as TextChannel).send({ + content: pingContent, + embeds: [initialEmbed], + components: [createGiveawayButtons()], + allowedMentions: { + roles: session.pingRoleId ? [session.pingRoleId] : [], + }, + }); + + const createdGiveaway = await createGiveaway({ + channelId: channel.id, + messageId: giveawayMessage.id, + endAt: session.endTime, + prize: session.prize, + winnerCount: session.winnerCount, + hostId: interaction.user.id, + requirements: { + level: session.requirements?.level, + roleId: session.requirements?.roleId, + messageCount: session.requirements?.messageCount, + requireAll: session.requirements.requireAll, + }, + bonuses: session.bonusEntries, + }); + + const updatedEmbed = createGiveawayEmbed({ + id: createdGiveaway.id, + prize: session.prize, + endTime: session.endTime, + winnerCount: session.winnerCount, + hostId: interaction.user.id, + participantCount: 0, + requiredLevel: session.requirements?.level, + requiredRoleId: session.requirements?.roleId, + requiredMessageCount: session.requirements?.messageCount, + requireAllCriteria: session.requirements.requireAll, + bonusEntries: session.bonusEntries, + }); + + await giveawayMessage.edit({ + embeds: [updatedEmbed], + components: [createGiveawayButtons()], + }); + + const timeLeft = session.endTime.getTime() - Date.now(); + setTimeout(() => { + processEndedGiveaway(interaction.client, giveawayMessage.id); + }, timeLeft); + + await interaction.editReply({ + content: `✅ Giveaway created successfully in <#${channel.id}>!\nIt will end `, + components: [], + embeds: [], + }); + + await deleteSession(interaction.user.id); + } catch (error) { + console.error('Error publishing giveaway:', error); + await interaction.followUp({ + content: + 'An error occurred while creating the giveaway. Please try again.', + flags: ['Ephemeral'], + }); + } +} + +export { + builder, + dropdowns, + handlers, + modals, + toggleRequirementLogic, + formatWinnerMentions, + selectGiveawayWinners, +}; diff --git a/src/util/giveaways/handlers.ts b/src/util/giveaways/handlers.ts new file mode 100644 index 0000000..0e5c06a --- /dev/null +++ b/src/util/giveaways/handlers.ts @@ -0,0 +1,452 @@ +import { + ButtonInteraction, + ModalSubmitInteraction, + StringSelectMenuInteraction, +} from 'discord.js'; + +import { addGiveawayParticipant, getGiveaway, getUserLevel } from '@/db/db.js'; +import { createGiveawayEmbed } from './giveawayManager.js'; +import { + checkUserRequirements, + createGiveawayButtons, + getSession, + parseRoleBonusEntries, + parseThresholdBonusEntries, + saveSession, +} from './utils.js'; +import { parseDuration } from '../helpers.js'; +import { showCustomDurationModal } from './modals.js'; +import { showBuilderStep } from './builder.js'; + +// ======================== +// Button Handlers +// ======================== + +/** + * Handles the entry for a giveaway. + * @param interaction - The interaction object from the button click + */ +export async function handleGiveawayEntry( + interaction: ButtonInteraction, +): Promise { + await interaction.deferUpdate(); + + try { + const messageId = interaction.message.id; + const giveaway = await getGiveaway(messageId); + + if (!giveaway || giveaway.status !== 'active') { + await interaction.followUp({ + content: 'This giveaway has ended or does not exist.', + flags: ['Ephemeral'], + }); + return; + } + + const [requirementsFailed, requirementsMet] = await checkUserRequirements( + interaction, + giveaway, + ); + const requireAll = giveaway.requireAllCriteria ?? true; + const totalRequirements = [ + giveaway.requiredLevel, + giveaway.requiredRoleId, + giveaway.requiredMessageCount, + ].filter(Boolean).length; + + if ( + (requireAll && requirementsFailed.length) || + (!requireAll && totalRequirements > 0 && !requirementsMet.length) + ) { + const reqType = requireAll ? 'ALL' : 'ANY ONE'; + await interaction.followUp({ + content: `You don't meet the requirements to enter this giveaway (${reqType} required):\n${requirementsFailed.join('\n')}`, + flags: ['Ephemeral'], + }); + return; + } + + const userData = await getUserLevel(interaction.user.id); + const member = await interaction.guild?.members.fetch(interaction.user.id); + let totalEntries = 1; + + giveaway.bonusEntries?.roles?.forEach((bonus) => { + if (member?.roles.cache.has(bonus.id)) { + totalEntries += bonus.entries; + } + }); + + giveaway.bonusEntries?.levels?.forEach((bonus) => { + if (userData.level >= bonus.threshold) { + totalEntries += bonus.entries; + } + }); + + giveaway.bonusEntries?.messages?.forEach((bonus) => { + if (userData.messagesSent >= bonus.threshold) { + totalEntries += bonus.entries; + } + }); + + const addResult = await addGiveawayParticipant( + messageId, + interaction.user.id, + totalEntries, + ); + + if (addResult === 'already_entered') { + await interaction.followUp({ + content: 'You have already entered this giveaway!', + flags: ['Ephemeral'], + }); + return; + } + + if (addResult === 'inactive') { + await interaction.followUp({ + content: 'This giveaway is no longer active.', + flags: ['Ephemeral'], + }); + return; + } + + if (addResult === 'error') { + await interaction.followUp({ + content: 'An error occurred while trying to enter the giveaway.', + flags: ['Ephemeral'], + }); + return; + } + + const updatedGiveaway = await getGiveaway(messageId); + if (!updatedGiveaway) { + console.error( + `Failed to fetch giveaway ${messageId} after successful entry.`, + ); + await interaction.followUp({ + content: `🎉 You have entered the giveaway with ${totalEntries} entries! Good luck! (Failed to update embed)`, + flags: ['Ephemeral'], + }); + return; + } + + const embed = createGiveawayEmbed({ + id: updatedGiveaway.id, + prize: updatedGiveaway.prize, + endTime: updatedGiveaway.endAt, + winnerCount: updatedGiveaway.winnerCount, + hostId: updatedGiveaway.hostId, + participantCount: updatedGiveaway.participants?.length || 0, + requiredLevel: updatedGiveaway.requiredLevel ?? undefined, + requiredRoleId: updatedGiveaway.requiredRoleId ?? undefined, + requiredMessageCount: updatedGiveaway.requiredMessageCount ?? undefined, + requireAllCriteria: updatedGiveaway.requireAllCriteria ?? undefined, + bonusEntries: updatedGiveaway.bonusEntries, + }); + + await interaction.message.edit({ + embeds: [embed], + components: [createGiveawayButtons()], + }); + + await interaction.followUp({ + content: `🎉 You have entered the giveaway with **${totalEntries}** entries! Good luck!`, + flags: ['Ephemeral'], + }); + } catch (error) { + console.error('Error handling giveaway entry:', error); + throw error; + } +} + +// ======================== +// Dropdown Handlers +// ======================== + +/** + * Handles the duration selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handleDurationSelect( + interaction: StringSelectMenuInteraction, +): Promise { + const duration = interaction.values[0]; + + if (duration === 'custom') { + showCustomDurationModal(interaction); + return; + } + + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + const durationMs = parseDuration(duration); + if (durationMs) { + session.duration = duration; + session.endTime = new Date(Date.now() + durationMs); + await saveSession(interaction.user.id, session); + } + + await showBuilderStep(interaction, session); +} + +/** + * Handles the winner selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handleWinnerSelect( + interaction: StringSelectMenuInteraction, +): Promise { + const winnerCount = parseInt(interaction.values[0]); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.winnerCount = winnerCount; + await saveSession(interaction.user.id, session); + + await showBuilderStep(interaction, session); +} + +/** + * Handles the channel selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handleChannelSelect( + interaction: StringSelectMenuInteraction, +): Promise { + try { + const channelId = interaction.values[0]; + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.channelId = channelId; + await saveSession(interaction.user.id, session); + + if (interaction.replied || interaction.deferred) { + await showBuilderStep(interaction, session); + } else { + await interaction.deferUpdate(); + await showBuilderStep(interaction, session); + } + } catch (error) { + console.error('Error in handleChannelSelect:', error); + if (!interaction.replied) { + await interaction + .reply({ + content: 'An error occurred while processing your selection.', + flags: ['Ephemeral'], + }) + .catch(console.error); + } + } +} + +/** + * Handles the requirements selection for the giveaway. + * @param interaction - The interaction object from the dropdown selection + */ +export async function handlePingRoleSelect( + interaction: StringSelectMenuInteraction, +): Promise { + const roleId = interaction.values[0]; + const session = await getSession(interaction.user.id); + + if (!session) return; + + session.pingRoleId = roleId; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +// ======================== +// Modal Handlers +// ======================== + +/** + * Handles the prize input for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handlePrizeSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const prize = interaction.fields.getTextInputValue('prize_input'); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.prize = prize; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the custom duration input for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleCustomDurationSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const customDuration = interaction.fields.getTextInputValue('duration_input'); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + const durationMs = parseDuration(customDuration); + if (!durationMs || durationMs <= 0) { + await interaction.reply({ + content: 'Invalid duration format. Please use formats like 1d, 12h, 30m.', + flags: ['Ephemeral'], + }); + return; + } + + session.duration = customDuration; + session.endTime = new Date(Date.now() + durationMs); + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the requirements submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleRequirementsSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const levelStr = interaction.fields.getTextInputValue('level_input'); + const messageStr = interaction.fields.getTextInputValue('message_input'); + const roleStr = interaction.fields.getTextInputValue('role_input'); + const session = await getSession(interaction.user.id); + + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + if (levelStr.trim()) { + const level = parseInt(levelStr); + if (!isNaN(level) && level > 0) { + session.requirements.level = level; + } else { + delete session.requirements.level; + } + } else { + delete session.requirements.level; + } + + if (messageStr.trim()) { + const messages = parseInt(messageStr); + if (!isNaN(messages) && messages > 0) { + session.requirements.messageCount = messages; + } else { + delete session.requirements.messageCount; + } + } else { + delete session.requirements.messageCount; + } + + if (roleStr.trim()) { + const roleId = roleStr.replace(/\D/g, ''); + if (roleId) { + session.requirements.roleId = roleId; + } else { + delete session.requirements.roleId; + } + } + + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the bonus entries submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleBonusEntriesSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) return; + + const rolesStr = interaction.fields.getTextInputValue('roles_input'); + const levelsStr = interaction.fields.getTextInputValue('levels_input'); + const messagesStr = interaction.fields.getTextInputValue('messages_input'); + + session.bonusEntries = { + roles: parseRoleBonusEntries(rolesStr), + levels: parseThresholdBonusEntries(levelsStr), + messages: parseThresholdBonusEntries(messagesStr), + }; + + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the ping role ID submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handlePingRoleIdSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const roleId = interaction.fields.getTextInputValue('role_input'); + const session = await getSession(interaction.user.id); + + if (!session) return; + + session.pingRoleId = roleId; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Handles the channel ID submission for the giveaway. + * @param interaction - The interaction object from the modal submission + */ +export async function handleChannelIdSubmit( + interaction: ModalSubmitInteraction, +): Promise { + const channelId = interaction.fields.getTextInputValue('channel_input'); + const session = await getSession(interaction.user.id); + + if (!session) return; + + session.channelId = channelId; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} diff --git a/src/util/giveaways/modals.ts b/src/util/giveaways/modals.ts new file mode 100644 index 0000000..3bef0e0 --- /dev/null +++ b/src/util/giveaways/modals.ts @@ -0,0 +1,186 @@ +import { + ActionRowBuilder, + ButtonInteraction, + ModalBuilder, + StringSelectMenuInteraction, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; + +/** + * Shows a modal to set the prize for a giveaway. + * @param interaction The interaction that triggered the modal. + */ +export async function showPrizeModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_prize_modal') + .setTitle('Set Giveaway Prize'); + + const prizeInput = new TextInputBuilder() + .setCustomId('prize_input') + .setLabel('What are you giving away?') + .setPlaceholder('e.g. Discord Nitro, Steam Game, etc.') + .setStyle(TextInputStyle.Short) + .setRequired(true); + + modal.addComponents( + new ActionRowBuilder().addComponents(prizeInput), + ); + await interaction.showModal(modal); +} + +/** + * Shows a modal to set custom duration. + * @param interaction The interaction that triggered the modal. + */ +export async function showCustomDurationModal( + interaction: StringSelectMenuInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_custom_duration') + .setTitle('Set Custom Duration'); + + const durationInput = new TextInputBuilder() + .setCustomId('duration_input') + .setLabel('Duration (e.g. 4h30m, 2d12h)') + .setPlaceholder('Enter custom duration') + .setStyle(TextInputStyle.Short) + .setRequired(true); + + modal.addComponents( + new ActionRowBuilder().addComponents(durationInput), + ); + await interaction.showModal(modal); +} + +/** + * Shows a modal to set entry requirements. + * @param interaction The interaction that triggered the modal. + */ +export async function showRequirementsModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_requirements_modal') + .setTitle('Set Entry Requirements'); + + const levelInput = new TextInputBuilder() + .setCustomId('level_input') + .setLabel('Min level (leave empty for none)') + .setPlaceholder('e.g. 10') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const messageInput = new TextInputBuilder() + .setCustomId('message_input') + .setLabel('Min messages (leave empty for none)') + .setPlaceholder('e.g. 100') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const roleInput = new TextInputBuilder() + .setCustomId('role_input') + .setLabel('Role ID (leave empty for none)') + .setPlaceholder('e.g. 123456789012345678') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(levelInput), + new ActionRowBuilder().addComponents(messageInput), + new ActionRowBuilder().addComponents(roleInput), + ); + + await interaction.showModal(modal); +} + +/** + * Shows a modal to set bonus entries for the giveaway. + * @param interaction The interaction that triggered the modal. + */ +export async function showBonusEntriesModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_bonus_entries_modal') + .setTitle('Bonus Entries Configuration'); + + const rolesInput = new TextInputBuilder() + .setCustomId('roles_input') + .setLabel('Role bonuses') + .setPlaceholder('format: roleId:entries,roleId:entries') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const levelsInput = new TextInputBuilder() + .setCustomId('levels_input') + .setLabel('Level bonuses') + .setPlaceholder('format: level:entries,level:entries') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + const messagesInput = new TextInputBuilder() + .setCustomId('messages_input') + .setLabel('Message bonuses') + .setPlaceholder('format: count:entries,count:entries') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(rolesInput), + new ActionRowBuilder().addComponents(levelsInput), + new ActionRowBuilder().addComponents(messagesInput), + ); + + await interaction.showModal(modal); +} + +/** + * Shows a modal to select a role to ping. + * @param interaction The interaction that triggered the modal. + */ +export async function showPingRoleSelectModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_ping_role_id_modal') + .setTitle('Enter Role ID'); + + const roleInput = new TextInputBuilder() + .setCustomId('role_input') + .setLabel('Role ID') + .setPlaceholder('Enter the role ID to ping') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(roleInput), + ); + await interaction.showModal(modal); +} + +/** + * Shows a modal to select the channel to host the giveaway. + * @param interaction The interaction that triggered the modal. + */ +export async function showChannelSelectModal( + interaction: ButtonInteraction, +): Promise { + const modal = new ModalBuilder() + .setCustomId('giveaway_channel_id_modal') + .setTitle('Select Channel for Giveaway'); + + const channelInput = new TextInputBuilder() + .setCustomId('channel_input') + .setLabel('Channel ID') + .setPlaceholder('Enter the channel ID to host the giveaway') + .setStyle(TextInputStyle.Short) + .setRequired(false); + + modal.addComponents( + new ActionRowBuilder().addComponents(channelInput), + ); + await interaction.showModal(modal); +} diff --git a/src/util/giveaways/types.ts b/src/util/giveaways/types.ts new file mode 100644 index 0000000..6cce4be --- /dev/null +++ b/src/util/giveaways/types.ts @@ -0,0 +1,39 @@ +export interface BonusEntries { + roles?: Array<{ id: string; entries: number }>; + levels?: Array<{ threshold: number; entries: number }>; + messages?: Array<{ threshold: number; entries: number }>; +} + +export interface GiveawaySession { + step: number; + prize?: string; + duration?: string; + endTime?: Date; + winnerCount: number; + channelId?: string; + requirements: { + level?: number; + roleId?: string; + messageCount?: number; + requireAll: boolean; + }; + pingRoleId?: string; + bonusEntries?: BonusEntries; +} + +export interface GiveawayEmbedParams { + id?: number; + prize: string; + endTime?: Date; + winnerCount?: number; + hostId: string; + participantCount?: number; + winnersIds?: string[]; + isEnded?: boolean; + footerText?: string; + requiredLevel?: number; + requiredRoleId?: string; + requiredMessageCount?: number; + requireAllCriteria?: boolean; + bonusEntries?: BonusEntries; +} diff --git a/src/util/giveaways/utils.ts b/src/util/giveaways/utils.ts new file mode 100644 index 0000000..d3128af --- /dev/null +++ b/src/util/giveaways/utils.ts @@ -0,0 +1,220 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, +} from 'discord.js'; + +import { del, getJson, setJson } from '@/db/redis.js'; +import { getUserLevel } from '@/db/db.js'; +import { GiveawaySession } from './types.js'; +import { SESSION_PREFIX, SESSION_TIMEOUT } from './constants.js'; +import { showBuilderStep } from './builder.js'; + +/** + * Select winners for the giveaway. + * @param participants - Array of participant IDs + * @param winnerCount - Number of winners to select + * @param forceWinners - Array of IDs to force as winners + * @param excludeIds - Array of IDs to exclude from selection + * @returns - Array of winner IDs + */ +export function selectGiveawayWinners( + participants: string[], + winnerCount: number, + forceWinners?: string[], + excludeIds?: string[], +): string[] { + if (forceWinners?.length) return forceWinners; + + const eligibleParticipants = excludeIds + ? participants.filter((p) => !excludeIds.includes(p)) + : participants; + + if (!eligibleParticipants.length) return []; + + const uniqueParticipants = [...new Set(eligibleParticipants)]; + + const actualWinnerCount = Math.min(winnerCount, uniqueParticipants.length); + const shuffled = uniqueParticipants.sort(() => 0.5 - Math.random()); + return shuffled.slice(0, actualWinnerCount); +} + +/** + * Format the winner mentions for the giveaway embed. + * @param winnerIds - Array of winner IDs + * @returns - Formatted string of winner mentions + */ +export function formatWinnerMentions(winnerIds?: string[]): string { + return winnerIds?.length + ? winnerIds.map((id) => `<@${id}>`).join(', ') + : 'No valid participants'; +} + +/** + * Create the giveaway button for users to enter. + * @returns - ActionRowBuilder with the giveaway button + */ +export function createGiveawayButtons(): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('enter_giveaway') + .setLabel('Enter Giveaway') + .setStyle(ButtonStyle.Success) + .setEmoji('🎉'), + ); +} + +/** + * Check if the user meets the giveaway requirements. + * @param interaction - Button interaction from Discord + * @param giveaway - Giveaway data + * @returns - Array of failed and met requirements + */ +export async function checkUserRequirements( + interaction: ButtonInteraction, + giveaway: any, +): Promise<[string[], string[]]> { + const requirementsFailed: string[] = []; + const requirementsMet: string[] = []; + + if (giveaway.requiredLevel) { + const userData = await getUserLevel(interaction.user.id); + if (userData.level < giveaway.requiredLevel) { + requirementsFailed.push( + `You need to be level ${giveaway.requiredLevel}+ to enter (you're level ${userData.level})`, + ); + } else { + requirementsMet.push(`Level requirement met (${userData.level})`); + } + } + + if (giveaway.requiredRoleId) { + const member = await interaction.guild?.members.fetch(interaction.user.id); + if (!member?.roles.cache.has(giveaway.requiredRoleId)) { + requirementsFailed.push( + `You need the <@&${giveaway.requiredRoleId}> role to enter`, + ); + } else { + requirementsMet.push('Role requirement met'); + } + } + + if (giveaway.requiredMessageCount) { + const userData = await getUserLevel(interaction.user.id); + if (userData.messagesSent < giveaway.requiredMessageCount) { + requirementsFailed.push( + `You need to have sent ${giveaway.requiredMessageCount}+ messages to enter (you've sent ${userData.messagesSent})`, + ); + } else { + requirementsMet.push( + `Message count requirement met (${userData.messagesSent})`, + ); + } + } + + return [requirementsFailed, requirementsMet]; +} + +/** + * Check if the user has already entered the giveaway. + * @param interaction - Button interaction from Discord + * @param giveaway - Giveaway data + * @returns - Boolean indicating if the user has entered + */ +export async function saveSession( + userId: string, + data: GiveawaySession, +): Promise { + const sessionToStore = { + ...data, + endTime: data.endTime?.toISOString(), + }; + await setJson(`${SESSION_PREFIX}${userId}`, sessionToStore, SESSION_TIMEOUT); +} + +/** + * Get the giveaway session for a user. + * @param userId - The ID of the user + * @returns - The user's giveaway session or null if not found + */ +export async function getSession( + userId: string, +): Promise { + const session = await getJson(`${SESSION_PREFIX}${userId}`); + if (!session) return null; + + return { + ...session, + endTime: session.endTime ? new Date(session.endTime) : undefined, + }; +} + +/** + * Delete the giveaway session for a user. + * @param userId - The ID of the user + */ +export async function deleteSession(userId: string): Promise { + await del(`${SESSION_PREFIX}${userId}`); +} + +/** + * Toggle the requirement logic for the giveaway session. + * @param interaction - Button interaction from Discord + */ +export async function toggleRequirementLogic( + interaction: ButtonInteraction, +): Promise { + const session = await getSession(interaction.user.id); + if (!session) { + await interaction.reply({ + content: 'Your giveaway session has expired. Please start over.', + flags: ['Ephemeral'], + }); + return; + } + + session.requirements.requireAll = !session.requirements.requireAll; + await saveSession(interaction.user.id, session); + await showBuilderStep(interaction, session); +} + +/** + * Parse the role bonus entries from a string input. + * @param input - String input in the format "roleId:entries,roleId:entries" + * @returns - Array of objects containing role ID and entries + */ +export function parseRoleBonusEntries( + input: string, +): Array<{ id: string; entries: number }> { + if (!input.trim()) return []; + + return input + .split(',') + .map((entry) => entry.trim().split(':')) + .filter(([key, value]) => key && value) + .map(([key, value]) => ({ + id: key, + entries: Number(value) || 0, + })); +} + +/** + * Parse the level bonus entries from a string input. + * @param input - String input in the format "level:entries,level:entries" + * @returns - Array of objects containing level and entries + */ +export function parseThresholdBonusEntries( + input: string, +): Array<{ threshold: number; entries: number }> { + if (!input.trim()) return []; + + return input + .split(',') + .map((entry) => entry.trim().split(':')) + .filter(([key, value]) => key && value) + .map(([key, value]) => ({ + threshold: Number(key) || 0, + entries: Number(value) || 0, + })); +} diff --git a/src/util/helpers.ts b/src/util/helpers.ts index dcc6fca..1301414 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -1,15 +1,31 @@ import Canvas from '@napi-rs/canvas'; -import path from 'path'; +import fs from 'node:fs'; +import path from 'node:path'; -import { AttachmentBuilder, Client, GuildMember, Guild } from 'discord.js'; +import { + AttachmentBuilder, + Client, + GuildMember, + Guild, + Interaction, + ButtonStyle, + ButtonBuilder, + ActionRowBuilder, + DiscordAPIError, +} from 'discord.js'; import { and, eq } from 'drizzle-orm'; -import { moderationTable } from '../db/schema.js'; -import { db, updateMember } from '../db/db.js'; +import { moderationTable } from '@/db/schema.js'; +import { db, getMember, handleDbError, updateMember } from '@/db/db.js'; import logAction from './logging/logAction.js'; const __dirname = path.resolve(); +/** + * Turns a duration string into milliseconds + * @param duration - The duration to parse + * @returns - The parsed duration in milliseconds + */ export function parseDuration(duration: string): number { const regex = /^(\d+)(s|m|h|d)$/; const match = duration.match(regex); @@ -30,18 +46,28 @@ export function parseDuration(duration: string): number { } } +/** + * Member banner types + */ interface generateMemberBannerTypes { member: GuildMember; width: number; height: number; } +/** + * Generates a welcome banner for a member + * @param member - The member to generate a banner for + * @param width - The width of the banner + * @param height - The height of the banner + * @returns - The generated banner + */ export async function generateMemberBanner({ member, width, height, -}: generateMemberBannerTypes) { - const welcomeBackground = path.join(__dirname, 'assets', 'welcome-bg.png'); +}: generateMemberBannerTypes): Promise { + const welcomeBackground = path.join(__dirname, 'assets', 'images', 'welcome-bg.png'); const canvas = Canvas.createCanvas(width, height); const context = canvas.getContext('2d'); const background = await Canvas.loadImage(welcomeBackground); @@ -92,12 +118,120 @@ export async function generateMemberBanner({ return attachment; } +/** + * Executes an unmute for a user + * @param client - The client to use + * @param guildId - The guild ID to unmute the user in + * @param userId - The user ID to unmute + * @param reason - The reason for the unmute + * @param moderator - The moderator who is unmuting the user + * @param alreadyUnmuted - Whether the user is already unmuted + */ +export async function executeUnmute( + client: Client, + guildId: string, + userId: string, + reason?: string, + moderator?: GuildMember, + alreadyUnmuted: boolean = false, +): Promise { + try { + const guild = await client.guilds.fetch(guildId); + let member; + + try { + member = await guild.members.fetch(userId); + if (!alreadyUnmuted) { + await member.timeout(null, reason ?? 'Temporary mute expired'); + } + } catch (error) { + console.log( + `Member ${userId} not found in server, just updating database`, + ); + } + + if (!(await getMember(userId))?.currentlyMuted) return; + + await db + .update(moderationTable) + .set({ active: false }) + .where( + and( + eq(moderationTable.discordId, userId), + eq(moderationTable.action, 'mute'), + eq(moderationTable.active, true), + ), + ); + + await updateMember({ + discordId: userId, + currentlyMuted: false, + }); + + if (member) { + await logAction({ + guild, + action: 'unmute', + target: member, + reason: reason ?? 'Temporary mute expired', + moderator: moderator ? moderator : guild.members.me!, + }); + } + } catch (error) { + console.error('Error executing unmute:', error); + + if (!(error instanceof DiscordAPIError && error.code === 10007)) { + handleDbError('Failed to execute unmute', error as Error); + } + } +} + +/** + * Loads all active mutes and schedules unmute events + * @param client - The client to use + * @param guild - The guild to load mutes for + */ +export async function loadActiveMutes( + client: Client, + guild: Guild, +): Promise { + try { + const activeMutes = await db + .select() + .from(moderationTable) + .where( + and( + eq(moderationTable.action, 'mute'), + eq(moderationTable.active, true), + ), + ); + + for (const mute of activeMutes) { + if (!mute.expiresAt) continue; + + const timeUntilUnmute = mute.expiresAt.getTime() - Date.now(); + if (timeUntilUnmute <= 0) { + await executeUnmute(client, guild.id, mute.discordId); + } + } + } catch (error) { + handleDbError('Failed to load active mutes', error as Error); + } +} + +/** + * Schedules an unban for a user + * @param client - The client to use + * @param guildId - The guild ID to unban the user from + * @param userId - The user ID to unban + * @param expiresAt - The date to unban the user at + */ export async function scheduleUnban( client: Client, guildId: string, userId: string, expiresAt: Date, -) { +): Promise { const timeUntilUnban = expiresAt.getTime() - Date.now(); if (timeUntilUnban > 0) { setTimeout(async () => { @@ -106,12 +240,19 @@ export async function scheduleUnban( } } +/** + * Executes an unban for a user + * @param client - The client to use + * @param guildId - The guild ID to unban the user from + * @param userId - The user ID to unban + * @param reason - The reason for the unban + */ export async function executeUnban( client: Client, guildId: string, userId: string, reason?: string, -) { +): Promise { try { const guild = await client.guilds.fetch(guildId); await guild.members.unban(userId, reason ?? 'Temporary ban expired'); @@ -136,30 +277,212 @@ export async function executeUnban( guild, action: 'unban', target: guild.members.cache.get(userId)!, - moderator: guild.members.cache.get(client.user!.id)!, + moderator: guild.members.me!, reason: reason ?? 'Temporary ban expired', }); } catch (error) { - console.error(`Failed to unban user ${userId}:`, error); + handleDbError(`Failed to unban user ${userId}`, error as Error); } } -export async function loadActiveBans(client: Client, guild: Guild) { - const activeBans = await db - .select() - .from(moderationTable) - .where( - and(eq(moderationTable.action, 'ban'), eq(moderationTable.active, true)), - ); +/** + * Loads all active bans and schedules unban events + * @param client - The client to use + * @param guild - The guild to load bans for + */ +export async function loadActiveBans( + client: Client, + guild: Guild, +): Promise { + try { + const activeBans = await db + .select() + .from(moderationTable) + .where( + and( + eq(moderationTable.action, 'ban'), + eq(moderationTable.active, true), + ), + ); - for (const ban of activeBans) { - if (!ban.expiresAt) continue; + for (const ban of activeBans) { + if (!ban.expiresAt) continue; - const timeUntilUnban = ban.expiresAt.getTime() - Date.now(); - if (timeUntilUnban <= 0) { - await executeUnban(client, guild.id, ban.discordId); + const timeUntilUnban = ban.expiresAt.getTime() - Date.now(); + if (timeUntilUnban <= 0) { + await executeUnban(client, guild.id, ban.discordId); + } else { + await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt); + } + } + } catch (error) { + handleDbError('Failed to load active bans', error as Error); + } +} + +/** + * Types for the roundRect function + */ +interface roundRectTypes { + ctx: Canvas.SKRSContext2D; + x: number; + y: number; + width: number; + height: number; + fill: boolean; + radius?: number; +} + +/** + * Creates a rounded rectangle + * @param ctx - The canvas context to use + * @param x - The x position of the rectangle + * @param y - The y position of the rectangle + * @param width - The width of the rectangle + * @param height - The height of the rectangle + * @param radius - The radius of the corners + * @param fill - Whether to fill the rectangle + */ +export function roundRect({ + ctx, + x, + y, + width, + height, + radius, + fill, +}: roundRectTypes): void { + if (typeof radius === 'undefined') { + radius = 5; + } + + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + + if (fill) { + ctx.fill(); + } else { + ctx.stroke(); + } +} + +/** + * Draw wrapped text in multiple lines + * @param ctx - The canvas context to use + * @param text - The text to draw + * @param x - The x position to draw the text + * @param y - The y position to draw the text + * @param maxWidth - The maximum width of the text + * @param lineHeight - The height of each line + */ +export function drawMultilineText( + ctx: Canvas.SKRSContext2D, + text: string, + x: number, + y: number, + maxWidth: number, + lineHeight: number, +) { + const words = text.split(' '); + let line = ''; + for (let i = 0; i < words.length; i++) { + const testLine = line + words[i] + ' '; + if (ctx.measureText(testLine).width > maxWidth && i > 0) { + ctx.fillText(line, x, y); + line = words[i] + ' '; + y += lineHeight; } else { - await scheduleUnban(client, guild.id, ban.discordId, ban.expiresAt); + line = testLine; } } + ctx.fillText(line, x, y); +} + +/** + * Checks if an interaction is valid + * @param interaction - The interaction to check + * @returns - Whether the interaction is valid + */ +export async function validateInteraction( + interaction: Interaction, +): Promise { + if (!interaction.inGuild()) return false; + if (!interaction.channel) return false; + + if (interaction.isMessageComponent()) { + try { + await interaction.channel.messages.fetch(interaction.message.id); + return true; + } catch { + return false; + } + } + + return true; +} + +/** + * Safely responds to an interaction + * @param interaction - The interaction to respond to + * @param content - The content to send + */ +export async function safelyRespond(interaction: Interaction, content: string) { + try { + if (!interaction.isRepliable()) return; + if (interaction.replied || interaction.deferred) { + await interaction.followUp({ content, flags: ['Ephemeral'] }); + } else { + await interaction.reply({ content, flags: ['Ephemeral'] }); + } + } catch (error) { + console.error('Failed to respond to interaction:', error); + } +} + +/** + * Creates pagination buttons for navigating through multiple pages + * @param totalPages - The total number of pages + * @param currentPage - The current page number + * @returns - The action row with pagination buttons + */ +export function createPaginationButtons( + totalPages: number, + currentPage: number, +): ActionRowBuilder { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('first') + .setLabel('⏮️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0), + new ButtonBuilder() + .setCustomId('prev') + .setLabel('◀️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === 0), + new ButtonBuilder() + .setCustomId('pageinfo') + .setLabel(`Page ${currentPage + 1}/${totalPages}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(true), + new ButtonBuilder() + .setCustomId('next') + .setLabel('▶️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === totalPages - 1), + new ButtonBuilder() + .setCustomId('last') + .setLabel('⏭️') + .setStyle(ButtonStyle.Primary) + .setDisabled(currentPage === totalPages - 1), + ); } diff --git a/src/util/levelingSystem.ts b/src/util/levelingSystem.ts new file mode 100644 index 0000000..e6bf4b9 --- /dev/null +++ b/src/util/levelingSystem.ts @@ -0,0 +1,309 @@ +import path from 'path'; +import Canvas, { GlobalFonts } from '@napi-rs/canvas'; +import { GuildMember, Message, AttachmentBuilder, Guild } from 'discord.js'; + +import { + addXpToUser, + db, + getUserLevel, + getUserRank, + handleDbError, +} from '@/db/db.js'; +import * as schema from '@/db/schema.js'; +import { loadConfig } from './configLoader.js'; +import { roundRect } from './helpers.js'; +import { processMessageAchievements } from './achievementManager.js'; + +const config = loadConfig(); + +const XP_COOLDOWN = config.leveling.xpCooldown * 1000; +const MIN_XP = config.leveling.minXpAwarded; +const MAX_XP = config.leveling.maxXpAwarded; + +const __dirname = path.resolve(); + +/** + * Calculates the amount of XP required to reach the given level + * @param level - The level to calculate the XP for + * @returns - The amount of XP required to reach the given level + */ +export const calculateXpForLevel = (level: number): number => { + if (level === 0) return 0; + return (5 / 6) * level * (2 * level * level + 27 * level + 91); +}; + +/** + * Calculates the level that corresponds to the given amount of XP + * @param xp - The amount of XP to calculate the level for + * @returns - The level that corresponds to the given amount of XP + */ +export const calculateLevelFromXp = (xp: number): number => { + if (xp < calculateXpForLevel(1)) return 0; + + let low = 1; + let high = 200; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const xpForMid = calculateXpForLevel(mid); + const xpForNext = calculateXpForLevel(mid + 1); + + if (xp >= xpForMid && xp < xpForNext) { + return mid; + } else if (xp < xpForMid) { + high = mid - 1; + } else { + low = mid + 1; + } + } + + return low - 1; +}; + +/** + * Gets the amount of XP required to reach the next level + * @param level - The level to calculate the XP for + * @param currentXp - The current amount of XP + * @returns - The amount of XP required to reach the next level + */ +export const getXpToNextLevel = (level: number, currentXp: number): number => { + if (level === 0) return calculateXpForLevel(1) - currentXp; + + const nextLevelXp = calculateXpForLevel(level + 1); + return nextLevelXp - currentXp; +}; + +/** + * Recalculates the levels for all users in the database + */ +export async function recalculateUserLevels() { + try { + const users = await db.select().from(schema.levelTable); + + for (const user of users) { + await addXpToUser(user.discordId, 0); + } + } catch (error) { + handleDbError('Failed to recalculate user levels', error as Error); + } +} + +/** + * Processes a message for XP + * @param message - The message to process for XP + * @returns - The result of processing the message + */ +export async function processMessage(message: Message) { + if (message.author.bot || !message.guild) return; + + try { + const userId = message.author.id; + const userData = await getUserLevel(userId); + const oldXp = userData.xp; + + if (userData.lastMessageTimestamp) { + const lastMessageTime = new Date(userData.lastMessageTimestamp).getTime(); + const currentTime = Date.now(); + + if (currentTime - lastMessageTime < XP_COOLDOWN) { + return null; + } + } + + let xpToAdd = Math.floor(Math.random() * (MAX_XP - MIN_XP + 1)) + MIN_XP; + + if (xpToAdd > 100) { + console.error( + `Unusually large XP amount generated: ${xpToAdd}. Capping at 100.`, + ); + xpToAdd = 100; + } + + const result = await addXpToUser(userId, xpToAdd); + + const newUserData = await getUserLevel(userId); + if (newUserData.xp > oldXp + 100) { + console.error( + `Detected abnormal XP increase: ${oldXp} → ${newUserData.xp}`, + ); + } + + await processMessageAchievements(message); + return result; + } catch (error) { + console.error('Error processing message for XP:', error); + return null; + } +} + +/** + * Generates a rank card for the given member + * @param member - The member to generate a rank card for + * @param userData - The user's level data + * @returns - The rank card as an attachment + */ +export async function generateRankCard( + member: GuildMember, + userData: schema.levelTableTypes, +) { + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Bold.ttf'), + 'Manrope Bold', + ); + GlobalFonts.registerFromPath( + path.join(__dirname, 'assets', 'fonts', 'Manrope-Regular.ttf'), + 'Manrope', + ); + + const userRank = await getUserRank(userData.discordId); + + const canvas = Canvas.createCanvas(934, 282); + const context = canvas.getContext('2d'); + + context.fillStyle = '#23272A'; + context.fillRect(0, 0, canvas.width, canvas.height); + + context.fillStyle = '#2C2F33'; + roundRect({ + ctx: context, + x: 22, + y: 22, + width: 890, + height: 238, + radius: 20, + fill: true, + }); + + try { + const avatar = await Canvas.loadImage( + member.user.displayAvatarURL({ extension: 'png', size: 256 }), + ); + context.save(); + context.beginPath(); + context.arc(120, 141, 80, 0, Math.PI * 2); + context.closePath(); + context.clip(); + context.drawImage(avatar, 40, 61, 160, 160); + context.restore(); + } catch (error) { + console.error('Error loading avatar image:', error); + context.fillStyle = '#5865F2'; + context.beginPath(); + context.arc(120, 141, 80, 0, Math.PI * 2); + context.fill(); + } + + context.font = '38px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.fillText(member.user.username, 242, 142); + + context.font = '24px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.textAlign = 'end'; + context.fillText(`LEVEL ${userData.level}`, 890, 82); + + context.font = '24px "Manrope Bold"'; + context.fillStyle = '#FFFFFF'; + context.fillText(`RANK #${userRank}`, 890, 122); + + const barWidth = 615; + const barHeight = 38; + const barX = 242; + const barY = 182; + + const currentLevel = userData.level; + const currentLevelXp = calculateXpForLevel(currentLevel); + const nextLevelXp = calculateXpForLevel(currentLevel + 1); + + const xpNeededForNextLevel = nextLevelXp - currentLevelXp; + + let xpIntoCurrentLevel; + if (currentLevel === 0) { + xpIntoCurrentLevel = userData.xp; + } else { + xpIntoCurrentLevel = userData.xp - currentLevelXp; + } + + const progress = Math.max( + 0, + Math.min(xpIntoCurrentLevel / xpNeededForNextLevel, 1), + ); + + context.fillStyle = '#484b4E'; + roundRect({ + ctx: context, + x: barX, + y: barY, + width: barWidth, + height: barHeight, + radius: barHeight / 2, + fill: true, + }); + + if (progress > 0) { + context.fillStyle = '#5865F2'; + roundRect({ + ctx: context, + x: barX, + y: barY, + width: barWidth * progress, + height: barHeight, + radius: barHeight / 2, + fill: true, + }); + } + + context.textAlign = 'center'; + context.font = '20px "Manrope"'; + context.fillStyle = '#A0A0A0'; + context.fillText( + `${xpIntoCurrentLevel.toLocaleString()} / ${xpNeededForNextLevel.toLocaleString()} XP`, + barX + barWidth / 2, + barY + barHeight / 2 + 7, + ); + + return new AttachmentBuilder(canvas.toBuffer('image/png'), { + name: 'rank-card.png', + }); +} + +/** + * Assigns level roles to a user based on their new level + * @param guild - The guild to assign roles in + * @param userId - The userId of the user to assign roles to + * @param newLevel - The new level of the user + * @returns - The highest role that was assigned + */ +export async function checkAndAssignLevelRoles( + guild: Guild, + userId: string, + newLevel: number, +) { + try { + if (!config.roles.levelRoles || config.roles.levelRoles.length === 0) { + return; + } + + const member = await guild.members.fetch(userId); + if (!member) return; + + const rolesToAdd = config.roles.levelRoles + .filter((role) => role.level <= newLevel) + .map((role) => role.roleId); + + if (rolesToAdd.length === 0) return; + + const newRolesToAdd = rolesToAdd.filter( + (roleId) => !member.roles.cache.has(roleId), + ); + + if (newRolesToAdd.length > 0) { + await member.roles.add(newRolesToAdd); + } + + const highestRole = rolesToAdd[rolesToAdd.length - 1]; + return highestRole; + } catch (error) { + console.error('Error assigning level roles:', error); + } +} diff --git a/src/util/logging/constants.ts b/src/util/logging/constants.ts index 85ed0bf..d9c3b60 100644 --- a/src/util/logging/constants.ts +++ b/src/util/logging/constants.ts @@ -1,6 +1,9 @@ import { ChannelType } from 'discord.js'; import { LogActionType } from './types'; +/** + * Colors for different actions + */ export const ACTION_COLORS: Record = { // Danger actions - Red ban: 0xff0000, @@ -31,6 +34,9 @@ export const ACTION_COLORS: Record = { default: 0x0099ff, }; +/** + * Emojis for different actions + */ export const ACTION_EMOJIS: Record = { roleCreate: '⭐', roleDelete: '🗑️', @@ -54,6 +60,9 @@ export const ACTION_EMOJIS: Record = { roleRemove: '➖', }; +/** + * Types of channels + */ export const CHANNEL_TYPES: Record = { [ChannelType.GuildText]: 'Text Channel', [ChannelType.GuildVoice]: 'Voice Channel', diff --git a/src/util/logging/logAction.ts b/src/util/logging/logAction.ts index 7becbe5..a78ea48 100644 --- a/src/util/logging/logAction.ts +++ b/src/util/logging/logAction.ts @@ -1,10 +1,10 @@ import { - TextChannel, ButtonStyle, ButtonBuilder, ActionRowBuilder, GuildChannel, } from 'discord.js'; + import { LogActionPayload, ModerationLogAction, @@ -22,10 +22,18 @@ import { getPermissionDifference, getPermissionNames, } from './utils.js'; +import { loadConfig } from '../configLoader.js'; -export default async function logAction(payload: LogActionPayload) { - const logChannel = payload.guild.channels.cache.get('1007787977432383611'); - if (!logChannel || !(logChannel instanceof TextChannel)) { +/** + * Logs an action to the log channel + * @param payload - The payload to log + */ +export default async function logAction( + payload: LogActionPayload, +): Promise { + const config = loadConfig(); + const logChannel = payload.guild.channels.cache.get(config.channels.logs); + if (!logChannel?.isTextBased()) { console.error('Log channel not found or is not a Text Channel.'); return; } diff --git a/src/util/logging/types.ts b/src/util/logging/types.ts index 36eed72..8319a3a 100644 --- a/src/util/logging/types.ts +++ b/src/util/logging/types.ts @@ -7,6 +7,9 @@ import { PermissionsBitField, } from 'discord.js'; +/** + * Moderation log action types + */ export type ModerationActionType = | 'ban' | 'kick' @@ -14,23 +17,38 @@ export type ModerationActionType = | 'unban' | 'unmute' | 'warn'; +/** + * Message log action types + */ export type MessageActionType = 'messageDelete' | 'messageEdit'; +/** + * Member log action types + */ export type MemberActionType = | 'memberJoin' | 'memberLeave' | 'memberUsernameUpdate' | 'memberNicknameUpdate'; +/** + * Role log action types + */ export type RoleActionType = | 'roleAdd' | 'roleRemove' | 'roleCreate' | 'roleDelete' | 'roleUpdate'; +/** + * Channel log action types + */ export type ChannelActionType = | 'channelCreate' | 'channelDelete' | 'channelUpdate'; +/** + * All log action types + */ export type LogActionType = | ModerationActionType | MessageActionType @@ -38,6 +56,9 @@ export type LogActionType = | RoleActionType | ChannelActionType; +/** + * Properties of a role + */ export type RoleProperties = { name: string; color: string; @@ -45,6 +66,9 @@ export type RoleProperties = { mentionable: boolean; }; +/** + * Base log action properties + */ export interface BaseLogAction { guild: Guild; action: LogActionType; @@ -53,6 +77,9 @@ export interface BaseLogAction { duration?: string; } +/** + * Log action properties for moderation actions + */ export interface ModerationLogAction extends BaseLogAction { action: ModerationActionType; target: GuildMember; @@ -61,6 +88,9 @@ export interface ModerationLogAction extends BaseLogAction { duration?: string; } +/** + * Log action properties for message actions + */ export interface MessageLogAction extends BaseLogAction { action: MessageActionType; message: Message; @@ -68,11 +98,17 @@ export interface MessageLogAction extends BaseLogAction { newContent?: string; } +/** + * Log action properties for member actions + */ export interface MemberLogAction extends BaseLogAction { action: 'memberJoin' | 'memberLeave'; member: GuildMember; } +/** + * Log action properties for member username or nickname updates + */ export interface MemberUpdateAction extends BaseLogAction { action: 'memberUsernameUpdate' | 'memberNicknameUpdate'; member: GuildMember; @@ -80,6 +116,9 @@ export interface MemberUpdateAction extends BaseLogAction { newValue: string; } +/** + * Log action properties for role actions + */ export interface RoleLogAction extends BaseLogAction { action: 'roleAdd' | 'roleRemove'; member: GuildMember; @@ -87,6 +126,9 @@ export interface RoleLogAction extends BaseLogAction { moderator?: GuildMember; } +/** + * Log action properties for role updates + */ export interface RoleUpdateAction extends BaseLogAction { action: 'roleUpdate'; role: Role; @@ -97,12 +139,18 @@ export interface RoleUpdateAction extends BaseLogAction { moderator?: GuildMember; } +/** + * Log action properties for role creation or deletion + */ export interface RoleCreateDeleteAction extends BaseLogAction { action: 'roleCreate' | 'roleDelete'; role: Role; moderator?: GuildMember; } +/** + * Log action properties for channel actions + */ export interface ChannelLogAction extends BaseLogAction { action: ChannelActionType; channel: GuildChannel; @@ -123,6 +171,9 @@ export interface ChannelLogAction extends BaseLogAction { moderator?: GuildMember; } +/** + * Payload for a log action + */ export type LogActionPayload = | ModerationLogAction | MessageLogAction diff --git a/src/util/logging/utils.ts b/src/util/logging/utils.ts index d47d0ce..c6c2977 100644 --- a/src/util/logging/utils.ts +++ b/src/util/logging/utils.ts @@ -5,9 +5,15 @@ import { EmbedField, PermissionsBitField, } from 'discord.js'; + import { LogActionPayload, LogActionType, RoleProperties } from './types.js'; import { ACTION_EMOJIS } from './constants.js'; +/** + * Formats a permission name to be more readable + * @param perm - The permission to format + * @returns - The formatted permission name + */ export const formatPermissionName = (perm: string): string => { return perm .split('_') @@ -15,6 +21,12 @@ export const formatPermissionName = (perm: string): string => { .join(' '); }; +/** + * Creates a field for a user + * @param user - The user to create a field for + * @param label - The label for the field + * @returns - The created field + */ export const createUserField = ( user: User | GuildMember, label = 'User', @@ -24,6 +36,12 @@ export const createUserField = ( inline: true, }); +/** + * Creates a field for a moderator + * @param moderator - The moderator to create a field for + * @param label - The label for the field + * @returns - The created field + */ export const createModeratorField = ( moderator?: GuildMember, label = 'Moderator', @@ -36,12 +54,23 @@ export const createModeratorField = ( } : null; +/** + * Creates a field for a channel + * @param channel - The channel to create a field for + * @returns - The created field + */ export const createChannelField = (channel: GuildChannel): EmbedField => ({ name: 'Channel', value: `<#${channel.id}>`, inline: true, }); +/** + * Creates a field for changed permissions + * @param oldPerms - The old permissions + * @param newPerms - The new permissions + * @returns - The created fields + */ export const createPermissionChangeFields = ( oldPerms: Readonly, newPerms: Readonly, @@ -84,6 +113,11 @@ export const createPermissionChangeFields = ( return fields; }; +/** + * Gets the names of the permissions in a bitfield + * @param permissions - The permissions to get the names of + * @returns - The names of the permissions + */ export const getPermissionNames = ( permissions: Readonly, ): string[] => { @@ -98,6 +132,12 @@ export const getPermissionNames = ( return names; }; +/** + * Compares two bitfields and returns the names of the permissions that are in the first bitfield but not the second + * @param a - The first bitfield + * @param b - The second bitfield + * @returns - The names of the permissions that are in the first bitfield but not the second + */ export const getPermissionDifference = ( a: Readonly, b: Readonly, @@ -114,6 +154,12 @@ export const getPermissionDifference = ( return names; }; +/** + * Creates a field for a role + * @param oldRole - The old role + * @param newRole - The new role + * @returns - The fields for the role changes + */ export const createRoleChangeFields = ( oldRole: Partial, newRole: Partial, @@ -153,6 +199,11 @@ export const createRoleChangeFields = ( return fields; }; +/** + * Gets the ID of the item that was logged + * @param payload - The payload to get the log item ID from + * @returns - The ID of the log item + */ export const getLogItemId = (payload: LogActionPayload): string => { switch (payload.action) { case 'roleCreate': @@ -188,6 +239,11 @@ export const getLogItemId = (payload: LogActionPayload): string => { } }; +/** + * Gets the emoji for an action + * @param action - The action to get an emoji for + * @returns - The emoji for the action + */ export const getEmojiForAction = (action: LogActionType): string => { return ACTION_EMOJIS[action] || '📝'; }; diff --git a/src/util/notificationHandler.ts b/src/util/notificationHandler.ts new file mode 100644 index 0000000..a2f8a58 --- /dev/null +++ b/src/util/notificationHandler.ts @@ -0,0 +1,151 @@ +import { Client, Guild, GuildMember } from 'discord.js'; +import { loadConfig } from './configLoader.js'; + +/** + * Types of notifications that can be sent + */ +export enum NotificationType { + // Redis notifications + REDIS_CONNECTION_LOST = 'REDIS_CONNECTION_LOST', + REDIS_CONNECTION_RESTORED = 'REDIS_CONNECTION_RESTORED', + + // Database notifications + DATABASE_CONNECTION_LOST = 'DATABASE_CONNECTION_LOST', + DATABASE_CONNECTION_RESTORED = 'DATABASE_CONNECTION_RESTORED', + + // Bot notifications + BOT_RESTARTING = 'BOT_RESTARTING', + BOT_ERROR = 'BOT_ERROR', +} + +/** + * Maps notification types to their messages + */ +const NOTIFICATION_MESSAGES = { + [NotificationType.REDIS_CONNECTION_LOST]: + '⚠️ **Redis Connection Lost**\n\nThe bot has lost connection to Redis after multiple retry attempts. Caching functionality is disabled until the connection is restored.', + [NotificationType.REDIS_CONNECTION_RESTORED]: + '✅ **Redis Connection Restored**\n\nThe bot has successfully reconnected to Redis. All caching functionality has been restored.', + + [NotificationType.DATABASE_CONNECTION_LOST]: + '🚨 **Database Connection Lost**\n\nThe bot has lost connection to the database after multiple retry attempts. The bot cannot function properly without database access and will shut down.', + [NotificationType.DATABASE_CONNECTION_RESTORED]: + '✅ **Database Connection Restored**\n\nThe bot has successfully reconnected to the database.', + + [NotificationType.BOT_RESTARTING]: + '🔄 **Bot Restarting**\n\nThe bot is being restarted. Services will be temporarily unavailable.', + [NotificationType.BOT_ERROR]: + '🚨 **Critical Bot Error**\n\nThe bot has encountered a critical error and may not function correctly.', +}; + +/** + * Creates a Discord-friendly timestamp string + * @returns Formatted Discord timestamp string + */ +function createDiscordTimestamp(): string { + const timestamp = Math.floor(Date.now() / 1000); + return ` ()`; +} + +/** + * Gets all managers with the Manager role + * @param guild - The guild to search in + * @returns Array of members with the Manager role + */ +async function getManagers(guild: Guild): Promise { + const config = loadConfig(); + const managerRoleId = config.roles?.staffRoles?.find( + (role) => role.name === 'Manager', + )?.roleId; + + if (!managerRoleId) { + console.warn('Manager role not found in config'); + return []; + } + + try { + await guild.members.fetch(); + + return Array.from( + guild.members.cache + .filter( + (member) => member.roles.cache.has(managerRoleId) && !member.user.bot, + ) + .values(), + ); + } catch (error) { + console.error('Error fetching managers:', error); + return []; + } +} + +/** + * Sends a notification to users with the Manager role + * @param client - Discord client instance + * @param type - Type of notification to send + * @param customMessage - Optional custom message to append + */ +export async function notifyManagers( + client: Client, + type: NotificationType, + customMessage?: string, +): Promise { + try { + const config = loadConfig(); + const guild = client.guilds.cache.get(config.guildId); + + if (!guild) { + console.error(`Guild with ID ${config.guildId} not found`); + return; + } + + const managers = await getManagers(guild); + + if (managers.length === 0) { + console.warn('No managers found to notify'); + return; + } + + const baseMessage = NOTIFICATION_MESSAGES[type]; + const timestamp = createDiscordTimestamp(); + const fullMessage = customMessage + ? `${baseMessage}\n\n${customMessage}` + : baseMessage; + + let successCount = 0; + for (const manager of managers) { + try { + await manager.send({ + content: `${fullMessage}\n\nTimestamp: ${timestamp}`, + }); + successCount++; + } catch (error) { + console.error( + `Failed to send DM to manager ${manager.user.tag}:`, + error, + ); + } + } + + console.log( + `Sent ${type} notification to ${successCount}/${managers.length} managers`, + ); + } catch (error) { + console.error('Error sending manager notifications:', error); + } +} + +/** + * Log a manager-level notification to the console + * @param type - Type of notification + * @param details - Additional details + */ +export function logManagerNotification( + type: NotificationType, + details?: string, +): void { + const baseMessage = NOTIFICATION_MESSAGES[type].split('\n')[0]; + console.warn( + `MANAGER NOTIFICATION: ${baseMessage}${details ? ` | ${details}` : ''}`, + ); +} diff --git a/src/util/undeployCommands.ts b/src/util/undeployCommands.ts new file mode 100644 index 0000000..a80cf3b --- /dev/null +++ b/src/util/undeployCommands.ts @@ -0,0 +1,36 @@ +import { REST, Routes } from 'discord.js'; +import { loadConfig } from './configLoader.js'; + +const config = loadConfig(); +const { token, clientId, guildId } = config; + +const rest = new REST({ version: '10' }).setToken(token); + +/** + * Undeploys all commands from the Discord API + */ +export const undeployCommands = async () => { + try { + console.log('Undeploying all commands from the Discord API...'); + + await rest.put(Routes.applicationGuildCommands(clientId, guildId), { + body: [], + }); + + console.log('Successfully undeployed all commands'); + } catch (error) { + console.error('Error undeploying commands:', error); + throw error; + } +}; + +if (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'))) { + undeployCommands() + .then(() => { + console.log('Undeploy process completed successfully'); + }) + .catch((err) => { + console.error('Undeploy process failed:', err); + process.exitCode = 1; + }); +} diff --git a/tsconfig.json b/tsconfig.json index a5e1167..2c00197 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,8 +30,10 @@ "module": "esnext" /* Specify what module code is generated. */, "rootDir": "src" /* Specify the root folder within your source files. */, "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + "baseUrl": "./" /* Specify the base directory to resolve non-relative module names. */, + "paths": { + "@/*": ["src/*", "./"] + } /* Specify a set of entries that re-map imports to additional lookup locations. */, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */ @@ -76,7 +78,7 @@ // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ + "isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */, // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, @@ -86,8 +88,8 @@ /* Type Checking */ "strict": true /* Enable all strict type-checking options. */, // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ + "strictNullChecks": true /* When type checking, take into account 'null' and 'undefined'. */, + "strictFunctionTypes": true /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */, // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ @@ -106,7 +108,11 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "plugins": [ + { "transform": "typescript-transform-paths" }, + { "transform": "typescript-transform-paths", "afterDeclarations": true } + ] }, "include": ["src/**/*"] } diff --git a/yarn.lock b/yarn.lock index 3c45680..e3f3905 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,215 @@ __metadata: version: 8 cacheKey: 10c0 +"@babel/code-frame@npm:^7.0.0": + version: 7.26.2 + resolution: "@babel/code-frame@npm:7.26.2" + dependencies: + "@babel/helper-validator-identifier": "npm:^7.25.9" + js-tokens: "npm:^4.0.0" + picocolors: "npm:^1.0.0" + checksum: 10c0/7d79621a6849183c415486af99b1a20b84737e8c11cd55b6544f688c51ce1fd710e6d869c3dd21232023da272a79b91efb3e83b5bc2dc65c1187c5fcd1b72ea8 + languageName: node + linkType: hard + +"@babel/helper-validator-identifier@npm:^7.25.9": + version: 7.25.9 + resolution: "@babel/helper-validator-identifier@npm:7.25.9" + checksum: 10c0/4fc6f830177b7b7e887ad3277ddb3b91d81e6c4a24151540d9d1023e8dc6b1c0505f0f0628ae653601eb4388a8db45c1c14b2c07a9173837aef7e4116456259d + languageName: node + linkType: hard + +"@commitlint/cli@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/cli@npm:19.8.0" + dependencies: + "@commitlint/format": "npm:^19.8.0" + "@commitlint/lint": "npm:^19.8.0" + "@commitlint/load": "npm:^19.8.0" + "@commitlint/read": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + tinyexec: "npm:^0.3.0" + yargs: "npm:^17.0.0" + bin: + commitlint: ./cli.js + checksum: 10c0/6931c62c18b848b2c7266ec0b2d3a690a9ec9f83151a67a89ef20a49c84d5e6ee8dbaee4aaec14b2bd1229fdd91c7a0b41b7fd68c52fff8632a0037d52bd6eb2 + languageName: node + linkType: hard + +"@commitlint/config-conventional@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/config-conventional@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + conventional-changelog-conventionalcommits: "npm:^7.0.2" + checksum: 10c0/c0e2ad4ee8b793ad08ce8f0fd242d8111c71c81eba53b652431b7852e02d3eef0a383e234b7574429f5d1876b712a915921f6ff61fdaccdf708cbbaf3fa1f2f0 + languageName: node + linkType: hard + +"@commitlint/config-validator@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/config-validator@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + ajv: "npm:^8.11.0" + checksum: 10c0/968b3041dbf1683f9da443c2998a53ced52e86b98a48862f39f303af69638c72b7409840c16b3ded27eaa1636bdbf6b2464f8a2628c40d8f14a66a5474359ed5 + languageName: node + linkType: hard + +"@commitlint/ensure@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/ensure@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + lodash.camelcase: "npm:^4.3.0" + lodash.kebabcase: "npm:^4.1.1" + lodash.snakecase: "npm:^4.1.1" + lodash.startcase: "npm:^4.4.0" + lodash.upperfirst: "npm:^4.3.1" + checksum: 10c0/5160dcf41c595496894cf1d075b4ee15c14b3689967d8693d4121689475d36853eceeb09fc4e07b6f002e7b8869e75418b0c1cd95d4ee32d062811301337875c + languageName: node + linkType: hard + +"@commitlint/execute-rule@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/execute-rule@npm:19.8.0" + checksum: 10c0/fee5848e41680935510c6eebe2afcfe3511e2ccc39686c555f2e2db0205345479c7dbd84e7a8a2b22c7700ce75e6442b24685fbc3a419b0ea91f83a0850c6489 + languageName: node + linkType: hard + +"@commitlint/format@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/format@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + chalk: "npm:^5.3.0" + checksum: 10c0/25de71d5b19c126e7e9f471dcf8015bc362ee94fec7ca0da866181832548cb4a04c18f732c8d7cc64641e896a33d0e199bd445edd9e0ef164b0e7bd7259b86b1 + languageName: node + linkType: hard + +"@commitlint/is-ignored@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/is-ignored@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + semver: "npm:^7.6.0" + checksum: 10c0/6f882266cca84fdc2a435cc01388b070c60cdda56dff6cb1bd98a443982d8bb90b186972450c733ee1190122882f53e715a7204d9fc9787b5303ca545985958c + languageName: node + linkType: hard + +"@commitlint/lint@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/lint@npm:19.8.0" + dependencies: + "@commitlint/is-ignored": "npm:^19.8.0" + "@commitlint/parse": "npm:^19.8.0" + "@commitlint/rules": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + checksum: 10c0/5ce1074e5ad1ed12158fb722d4d643be71c3ae35113c6b13faa71dd85a07eeafec50ef2fee3f3e6fccdbd8bf8684613aa097e287b54a7cbcae1f9f28e2b95e8d + languageName: node + linkType: hard + +"@commitlint/load@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/load@npm:19.8.0" + dependencies: + "@commitlint/config-validator": "npm:^19.8.0" + "@commitlint/execute-rule": "npm:^19.8.0" + "@commitlint/resolve-extends": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + chalk: "npm:^5.3.0" + cosmiconfig: "npm:^9.0.0" + cosmiconfig-typescript-loader: "npm:^6.1.0" + lodash.isplainobject: "npm:^4.0.6" + lodash.merge: "npm:^4.6.2" + lodash.uniq: "npm:^4.5.0" + checksum: 10c0/6826a015ce40ae6043ff45bf29c7d515822ea416ab2a2a6eec6a69e5ba81b71419cadd609070aa3695d59f5442c34e3c264889df343eb66595c130185db58bad + languageName: node + linkType: hard + +"@commitlint/message@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/message@npm:19.8.0" + checksum: 10c0/a7390fade33e381a17d53ec16081bd6915d61cf4eb326739ee4b4c1f3a4016f84e953dd273126fcf23deaf5ca2ed49d75c0e667bc159dcfb26cb37ce840d97a9 + languageName: node + linkType: hard + +"@commitlint/parse@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/parse@npm:19.8.0" + dependencies: + "@commitlint/types": "npm:^19.8.0" + conventional-changelog-angular: "npm:^7.0.0" + conventional-commits-parser: "npm:^5.0.0" + checksum: 10c0/ece54b76d2bf6eb620d972810a8db276a104cbd29db6a3c7eb661fc6eaf8212fda04a42920eac56831f65af77bc4a8e15260c2c0881f351289d93e4cf5371cde + languageName: node + linkType: hard + +"@commitlint/read@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/read@npm:19.8.0" + dependencies: + "@commitlint/top-level": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + git-raw-commits: "npm:^4.0.0" + minimist: "npm:^1.2.8" + tinyexec: "npm:^0.3.0" + checksum: 10c0/94b9156f67b95d0ca7dd9653e399b7129d0b84c4940dc79a5264148688ca01c70780ef235b67d344059e575938c9e0988af9fa7233a793dcd74f49f9278e0e68 + languageName: node + linkType: hard + +"@commitlint/resolve-extends@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/resolve-extends@npm:19.8.0" + dependencies: + "@commitlint/config-validator": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + global-directory: "npm:^4.0.1" + import-meta-resolve: "npm:^4.0.0" + lodash.mergewith: "npm:^4.6.2" + resolve-from: "npm:^5.0.0" + checksum: 10c0/7b05d0c9bc2171e1475baeef13d30d6d985e1dd9cb4652355484a8d4841797dffd3e80edd5c61182cbfab1a28f4180ccbdef87bfa8f4586e057e05e238f5b19b + languageName: node + linkType: hard + +"@commitlint/rules@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/rules@npm:19.8.0" + dependencies: + "@commitlint/ensure": "npm:^19.8.0" + "@commitlint/message": "npm:^19.8.0" + "@commitlint/to-lines": "npm:^19.8.0" + "@commitlint/types": "npm:^19.8.0" + checksum: 10c0/3d6e932dfbd4c6384d3b3ded66a9f886667988cae4b1ae091350198ae8ca5c703142f13ccd8b632a0d260fd48072f5bc67836c15e6d637033b97dac2c81c95dd + languageName: node + linkType: hard + +"@commitlint/to-lines@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/to-lines@npm:19.8.0" + checksum: 10c0/1a0f34805615f244f34471138cfd5c8a45531ec3d1a0254370835db817dd06ec14181a8b281cd508632cf217d6cf5148473984bf4736d74b275fe69b8cd40863 + languageName: node + linkType: hard + +"@commitlint/top-level@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/top-level@npm:19.8.0" + dependencies: + find-up: "npm:^7.0.0" + checksum: 10c0/04d39835bfb8d9f86b693d8d13bfe7e6566d48ac57e382e5139277bb0e5fa286645fe220c323fcb8e6569eea48ab26253c0eb4f6a142855a3a7b7565891ead7c + languageName: node + linkType: hard + +"@commitlint/types@npm:^19.8.0": + version: 19.8.0 + resolution: "@commitlint/types@npm:19.8.0" + dependencies: + "@types/conventional-commits-parser": "npm:^5.0.0" + chalk: "npm:^5.3.0" + checksum: 10c0/634a5db20110675da8ddf226f200c33f262c6e99d06853fd4a2f6d543e6cc7dfe48b045f7ae76bcce2e39595099bfebe6a5dd6da37ff2968733c1263b8d46644 + languageName: node + linkType: hard + "@cspotcode/source-map-support@npm:^0.8.0": version: 0.8.1 resolution: "@cspotcode/source-map-support@npm:0.8.1" @@ -14,18 +223,18 @@ __metadata: languageName: node linkType: hard -"@discordjs/builders@npm:^1.10.1": - version: 1.10.1 - resolution: "@discordjs/builders@npm:1.10.1" +"@discordjs/builders@npm:^1.11.1": + version: 1.11.1 + resolution: "@discordjs/builders@npm:1.11.1" dependencies: - "@discordjs/formatters": "npm:^0.6.0" + "@discordjs/formatters": "npm:^0.6.1" "@discordjs/util": "npm:^1.1.1" "@sapphire/shapeshift": "npm:^4.0.0" - discord-api-types: "npm:^0.37.119" + discord-api-types: "npm:^0.38.1" fast-deep-equal: "npm:^3.1.3" ts-mixer: "npm:^6.0.4" tslib: "npm:^2.6.3" - checksum: 10c0/25e24c8bf389b7ef87ca56f1771ac76cd56552862c800cb4bd8a964f23991d4e6026e2ef35de0c688620dd8ae899348d4d63cea92d2d901eea1dc5aac1c17deb + checksum: 10c0/ddfbc60350c6217fc5b1a43cdd1cfc74cab82d44d18ebca1af385c6d3b60861b5cce6782f8bb28ac8ed25dea5850f86e05b43c57931fbd7e9882e0d3ab146c35 languageName: node linkType: hard @@ -43,29 +252,29 @@ __metadata: languageName: node linkType: hard -"@discordjs/formatters@npm:^0.6.0": - version: 0.6.0 - resolution: "@discordjs/formatters@npm:0.6.0" +"@discordjs/formatters@npm:^0.6.1": + version: 0.6.1 + resolution: "@discordjs/formatters@npm:0.6.1" dependencies: - discord-api-types: "npm:^0.37.114" - checksum: 10c0/850fe67a5cea9109aa8568cc9521666dd5f2b96845e3cfa78c24ca53ef2c9f8d1411f3dcc20b268fb6e9703ed5a8c60cfb1aeab0a6718a650506edb64f3a67fd + discord-api-types: "npm:^0.38.1" + checksum: 10c0/463759a10be7af9c82862f17f611aa1c737aa8dbd31816c89e9ce14c79eef0f86f57a18c3a64e180c03f9a482aa790427a4c7584dc09e76482b328da012561c2 languageName: node linkType: hard -"@discordjs/rest@npm:^2.4.3": - version: 2.4.3 - resolution: "@discordjs/rest@npm:2.4.3" +"@discordjs/rest@npm:^2.5.0": + version: 2.5.0 + resolution: "@discordjs/rest@npm:2.5.0" dependencies: "@discordjs/collection": "npm:^2.1.1" "@discordjs/util": "npm:^1.1.1" "@sapphire/async-queue": "npm:^1.5.3" "@sapphire/snowflake": "npm:^3.5.3" "@vladfrangu/async_event_emitter": "npm:^2.4.6" - discord-api-types: "npm:^0.37.119" + discord-api-types: "npm:^0.38.1" magic-bytes.js: "npm:^1.10.0" tslib: "npm:^2.6.3" undici: "npm:6.21.1" - checksum: 10c0/68849edfa995daf9b88dbaad7d27a8737311910d862de282d2b8c9bf63e3d4bbc45dde1fed10bfabe5221ca94be5fb52b45ef789058b4ede29939f125b694f2f + checksum: 10c0/9bfafd34c684240395d3ce1ae7a930de426f379940ae526466714e8066bf06c6a6a4f45e76a39f32d85fee7eb74bcb6ff7b72669cec722f9c3e1828a96ec3ad6 languageName: node linkType: hard @@ -76,20 +285,20 @@ __metadata: languageName: node linkType: hard -"@discordjs/ws@npm:^1.2.1": - version: 1.2.1 - resolution: "@discordjs/ws@npm:1.2.1" +"@discordjs/ws@npm:^1.2.2": + version: 1.2.2 + resolution: "@discordjs/ws@npm:1.2.2" dependencies: "@discordjs/collection": "npm:^2.1.0" - "@discordjs/rest": "npm:^2.4.3" + "@discordjs/rest": "npm:^2.5.0" "@discordjs/util": "npm:^1.1.0" "@sapphire/async-queue": "npm:^1.5.2" "@types/ws": "npm:^8.5.10" "@vladfrangu/async_event_emitter": "npm:^2.2.4" - discord-api-types: "npm:^0.37.119" + discord-api-types: "npm:^0.38.1" tslib: "npm:^2.6.2" ws: "npm:^8.17.0" - checksum: 10c0/7cda15b070891eed831512d1979ed6a30b82d6fe9967f091216f1ae658a26a3f49e783415b4466b4375a5791283abd18e6883722c2f3fc07a4721d4b23eb6b60 + checksum: 10c0/de76890d1497bde76b2f33d936bc2cf13273679833b9d7b768ad3ee156bb80f642499217b52db1367243dc5a3279f9536fc98de29f99826d9b4efd3210497be1 languageName: node linkType: hard @@ -120,16 +329,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/aix-ppc64@npm:0.19.12" +"@esbuild/aix-ppc64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/aix-ppc64@npm:0.25.0" conditions: os=aix & cpu=ppc64 languageName: node linkType: hard -"@esbuild/aix-ppc64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/aix-ppc64@npm:0.25.0" +"@esbuild/aix-ppc64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/aix-ppc64@npm:0.25.2" conditions: os=aix & cpu=ppc64 languageName: node linkType: hard @@ -141,16 +350,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/android-arm64@npm:0.19.12" +"@esbuild/android-arm64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/android-arm64@npm:0.25.0" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@esbuild/android-arm64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/android-arm64@npm:0.25.0" +"@esbuild/android-arm64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/android-arm64@npm:0.25.2" conditions: os=android & cpu=arm64 languageName: node linkType: hard @@ -162,16 +371,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-arm@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/android-arm@npm:0.19.12" +"@esbuild/android-arm@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/android-arm@npm:0.25.0" conditions: os=android & cpu=arm languageName: node linkType: hard -"@esbuild/android-arm@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/android-arm@npm:0.25.0" +"@esbuild/android-arm@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/android-arm@npm:0.25.2" conditions: os=android & cpu=arm languageName: node linkType: hard @@ -183,16 +392,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/android-x64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/android-x64@npm:0.19.12" +"@esbuild/android-x64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/android-x64@npm:0.25.0" conditions: os=android & cpu=x64 languageName: node linkType: hard -"@esbuild/android-x64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/android-x64@npm:0.25.0" +"@esbuild/android-x64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/android-x64@npm:0.25.2" conditions: os=android & cpu=x64 languageName: node linkType: hard @@ -204,16 +413,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/darwin-arm64@npm:0.19.12" +"@esbuild/darwin-arm64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/darwin-arm64@npm:0.25.0" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@esbuild/darwin-arm64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/darwin-arm64@npm:0.25.0" +"@esbuild/darwin-arm64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/darwin-arm64@npm:0.25.2" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard @@ -225,16 +434,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/darwin-x64@npm:0.19.12" +"@esbuild/darwin-x64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/darwin-x64@npm:0.25.0" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@esbuild/darwin-x64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/darwin-x64@npm:0.25.0" +"@esbuild/darwin-x64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/darwin-x64@npm:0.25.2" conditions: os=darwin & cpu=x64 languageName: node linkType: hard @@ -246,16 +455,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/freebsd-arm64@npm:0.19.12" +"@esbuild/freebsd-arm64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/freebsd-arm64@npm:0.25.0" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/freebsd-arm64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/freebsd-arm64@npm:0.25.0" +"@esbuild/freebsd-arm64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/freebsd-arm64@npm:0.25.2" conditions: os=freebsd & cpu=arm64 languageName: node linkType: hard @@ -267,16 +476,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/freebsd-x64@npm:0.19.12" +"@esbuild/freebsd-x64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/freebsd-x64@npm:0.25.0" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard -"@esbuild/freebsd-x64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/freebsd-x64@npm:0.25.0" +"@esbuild/freebsd-x64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/freebsd-x64@npm:0.25.2" conditions: os=freebsd & cpu=x64 languageName: node linkType: hard @@ -288,16 +497,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/linux-arm64@npm:0.19.12" +"@esbuild/linux-arm64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/linux-arm64@npm:0.25.0" conditions: os=linux & cpu=arm64 languageName: node linkType: hard -"@esbuild/linux-arm64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/linux-arm64@npm:0.25.0" +"@esbuild/linux-arm64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/linux-arm64@npm:0.25.2" conditions: os=linux & cpu=arm64 languageName: node linkType: hard @@ -309,16 +518,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/linux-arm@npm:0.19.12" +"@esbuild/linux-arm@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/linux-arm@npm:0.25.0" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@esbuild/linux-arm@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/linux-arm@npm:0.25.0" +"@esbuild/linux-arm@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/linux-arm@npm:0.25.2" conditions: os=linux & cpu=arm languageName: node linkType: hard @@ -330,16 +539,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/linux-ia32@npm:0.19.12" +"@esbuild/linux-ia32@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/linux-ia32@npm:0.25.0" conditions: os=linux & cpu=ia32 languageName: node linkType: hard -"@esbuild/linux-ia32@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/linux-ia32@npm:0.25.0" +"@esbuild/linux-ia32@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/linux-ia32@npm:0.25.2" conditions: os=linux & cpu=ia32 languageName: node linkType: hard @@ -351,16 +560,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/linux-loong64@npm:0.19.12" +"@esbuild/linux-loong64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/linux-loong64@npm:0.25.0" conditions: os=linux & cpu=loong64 languageName: node linkType: hard -"@esbuild/linux-loong64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/linux-loong64@npm:0.25.0" +"@esbuild/linux-loong64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/linux-loong64@npm:0.25.2" conditions: os=linux & cpu=loong64 languageName: node linkType: hard @@ -372,16 +581,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/linux-mips64el@npm:0.19.12" +"@esbuild/linux-mips64el@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/linux-mips64el@npm:0.25.0" conditions: os=linux & cpu=mips64el languageName: node linkType: hard -"@esbuild/linux-mips64el@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/linux-mips64el@npm:0.25.0" +"@esbuild/linux-mips64el@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/linux-mips64el@npm:0.25.2" conditions: os=linux & cpu=mips64el languageName: node linkType: hard @@ -393,16 +602,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/linux-ppc64@npm:0.19.12" +"@esbuild/linux-ppc64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/linux-ppc64@npm:0.25.0" conditions: os=linux & cpu=ppc64 languageName: node linkType: hard -"@esbuild/linux-ppc64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/linux-ppc64@npm:0.25.0" +"@esbuild/linux-ppc64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/linux-ppc64@npm:0.25.2" conditions: os=linux & cpu=ppc64 languageName: node linkType: hard @@ -414,16 +623,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/linux-riscv64@npm:0.19.12" +"@esbuild/linux-riscv64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/linux-riscv64@npm:0.25.0" conditions: os=linux & cpu=riscv64 languageName: node linkType: hard -"@esbuild/linux-riscv64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/linux-riscv64@npm:0.25.0" +"@esbuild/linux-riscv64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/linux-riscv64@npm:0.25.2" conditions: os=linux & cpu=riscv64 languageName: node linkType: hard @@ -435,16 +644,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/linux-s390x@npm:0.19.12" +"@esbuild/linux-s390x@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/linux-s390x@npm:0.25.0" conditions: os=linux & cpu=s390x languageName: node linkType: hard -"@esbuild/linux-s390x@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/linux-s390x@npm:0.25.0" +"@esbuild/linux-s390x@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/linux-s390x@npm:0.25.2" conditions: os=linux & cpu=s390x languageName: node linkType: hard @@ -456,16 +665,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/linux-x64@npm:0.19.12" +"@esbuild/linux-x64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/linux-x64@npm:0.25.0" conditions: os=linux & cpu=x64 languageName: node linkType: hard -"@esbuild/linux-x64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/linux-x64@npm:0.25.0" +"@esbuild/linux-x64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/linux-x64@npm:0.25.2" conditions: os=linux & cpu=x64 languageName: node linkType: hard @@ -477,16 +686,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.18.20": - version: 0.18.20 - resolution: "@esbuild/netbsd-x64@npm:0.18.20" - conditions: os=netbsd & cpu=x64 +"@esbuild/netbsd-arm64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/netbsd-arm64@npm:0.25.2" + conditions: os=netbsd & cpu=arm64 languageName: node linkType: hard -"@esbuild/netbsd-x64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/netbsd-x64@npm:0.19.12" +"@esbuild/netbsd-x64@npm:0.18.20": + version: 0.18.20 + resolution: "@esbuild/netbsd-x64@npm:0.18.20" conditions: os=netbsd & cpu=x64 languageName: node linkType: hard @@ -498,6 +707,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/netbsd-x64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/netbsd-x64@npm:0.25.2" + conditions: os=netbsd & cpu=x64 + languageName: node + linkType: hard + "@esbuild/openbsd-arm64@npm:0.25.0": version: 0.25.0 resolution: "@esbuild/openbsd-arm64@npm:0.25.0" @@ -505,6 +721,13 @@ __metadata: languageName: node linkType: hard +"@esbuild/openbsd-arm64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/openbsd-arm64@npm:0.25.2" + conditions: os=openbsd & cpu=arm64 + languageName: node + linkType: hard + "@esbuild/openbsd-x64@npm:0.18.20": version: 0.18.20 resolution: "@esbuild/openbsd-x64@npm:0.18.20" @@ -512,16 +735,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/openbsd-x64@npm:0.19.12" +"@esbuild/openbsd-x64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/openbsd-x64@npm:0.25.0" conditions: os=openbsd & cpu=x64 languageName: node linkType: hard -"@esbuild/openbsd-x64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/openbsd-x64@npm:0.25.0" +"@esbuild/openbsd-x64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/openbsd-x64@npm:0.25.2" conditions: os=openbsd & cpu=x64 languageName: node linkType: hard @@ -533,16 +756,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/sunos-x64@npm:0.19.12" +"@esbuild/sunos-x64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/sunos-x64@npm:0.25.0" conditions: os=sunos & cpu=x64 languageName: node linkType: hard -"@esbuild/sunos-x64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/sunos-x64@npm:0.25.0" +"@esbuild/sunos-x64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/sunos-x64@npm:0.25.2" conditions: os=sunos & cpu=x64 languageName: node linkType: hard @@ -554,16 +777,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/win32-arm64@npm:0.19.12" +"@esbuild/win32-arm64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/win32-arm64@npm:0.25.0" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard -"@esbuild/win32-arm64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/win32-arm64@npm:0.25.0" +"@esbuild/win32-arm64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/win32-arm64@npm:0.25.2" conditions: os=win32 & cpu=arm64 languageName: node linkType: hard @@ -575,16 +798,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/win32-ia32@npm:0.19.12" +"@esbuild/win32-ia32@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/win32-ia32@npm:0.25.0" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard -"@esbuild/win32-ia32@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/win32-ia32@npm:0.25.0" +"@esbuild/win32-ia32@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/win32-ia32@npm:0.25.2" conditions: os=win32 & cpu=ia32 languageName: node linkType: hard @@ -596,16 +819,16 @@ __metadata: languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.19.12": - version: 0.19.12 - resolution: "@esbuild/win32-x64@npm:0.19.12" +"@esbuild/win32-x64@npm:0.25.0": + version: 0.25.0 + resolution: "@esbuild/win32-x64@npm:0.25.0" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@esbuild/win32-x64@npm:0.25.0": - version: 0.25.0 - resolution: "@esbuild/win32-x64@npm:0.25.0" +"@esbuild/win32-x64@npm:0.25.2": + version: 0.25.2 + resolution: "@esbuild/win32-x64@npm:0.25.2" conditions: os=win32 & cpu=x64 languageName: node linkType: hard @@ -639,30 +862,30 @@ __metadata: languageName: node linkType: hard -"@eslint/config-array@npm:^0.19.2": - version: 0.19.2 - resolution: "@eslint/config-array@npm:0.19.2" +"@eslint/config-array@npm:^0.20.0": + version: 0.20.0 + resolution: "@eslint/config-array@npm:0.20.0" dependencies: "@eslint/object-schema": "npm:^2.1.6" debug: "npm:^4.3.1" minimatch: "npm:^3.1.2" - checksum: 10c0/dd68da9abb32d336233ac4fe0db1e15a0a8d794b6e69abb9e57545d746a97f6f542496ff9db0d7e27fab1438546250d810d90b1904ac67677215b8d8e7573f3d + checksum: 10c0/94bc5d0abb96dc5295ff559925242ff75a54eacfb3576677e95917e42f7175e1c4b87bf039aa2a872f949b4852ad9724bf2f7529aaea6b98f28bb3fca7f1d659 languageName: node linkType: hard -"@eslint/config-helpers@npm:^0.2.0": - version: 0.2.0 - resolution: "@eslint/config-helpers@npm:0.2.0" - checksum: 10c0/743a64653e13177029108f57ab47460ded08e3412c86216a14b7e8ab2dc79c2b64be45bf55c5ef29f83692a707dc34cf1e9217e4b8b4b272a0d9b691fdaf6a2a +"@eslint/config-helpers@npm:^0.2.1": + version: 0.2.1 + resolution: "@eslint/config-helpers@npm:0.2.1" + checksum: 10c0/3e829a78b0bb4f7c44384ba1df3986e5de24b7f440ad5c6bb3cfc366ded773a869ca9ee8d212b5a563ae94596c5940dea6fd2ea1ee53a84c6241ac953dcb8bb7 languageName: node linkType: hard -"@eslint/core@npm:^0.12.0": - version: 0.12.0 - resolution: "@eslint/core@npm:0.12.0" +"@eslint/core@npm:^0.13.0": + version: 0.13.0 + resolution: "@eslint/core@npm:0.13.0" dependencies: "@types/json-schema": "npm:^7.0.15" - checksum: 10c0/d032af81195bb28dd800c2b9617548c6c2a09b9490da3c5537fd2a1201501666d06492278bb92cfccac1f7ac249e58601dd87f813ec0d6a423ef0880434fa0c3 + checksum: 10c0/ba724a7df7ed9dab387481f11d0d0f708180f40be93acce2c21dacca625c5867de3528760c42f1c457ccefe6a669d525ff87b779017eabc0d33479a36300797b languageName: node linkType: hard @@ -707,10 +930,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.23.0, @eslint/js@npm:^9.23.0": - version: 9.23.0 - resolution: "@eslint/js@npm:9.23.0" - checksum: 10c0/4e70869372b6325389e0ab51cac6d3062689807d1cef2c3434857571422ce11dde3c62777af85c382b9f94d937127598d605d2086787f08611351bf99faded81 +"@eslint/js@npm:9.25.1, @eslint/js@npm:^9.25.1": + version: 9.25.1 + resolution: "@eslint/js@npm:9.25.1" + checksum: 10c0/87d86b512ab109bfd3b9317ced3220ea3d444ac3bfa7abd853ca7f724d72c36e213062f9def16a632365d97dc29e0094312e3682a9767590ee6f43b3d5d873fd languageName: node linkType: hard @@ -721,13 +944,13 @@ __metadata: languageName: node linkType: hard -"@eslint/plugin-kit@npm:^0.2.7": - version: 0.2.7 - resolution: "@eslint/plugin-kit@npm:0.2.7" +"@eslint/plugin-kit@npm:^0.2.8": + version: 0.2.8 + resolution: "@eslint/plugin-kit@npm:0.2.8" dependencies: - "@eslint/core": "npm:^0.12.0" + "@eslint/core": "npm:^0.13.0" levn: "npm:^0.4.1" - checksum: 10c0/0a1aff1ad63e72aca923217e556c6dfd67d7cd121870eb7686355d7d1475d569773528a8b2111b9176f3d91d2ea81f7413c34600e8e5b73d59e005d70780b633 + checksum: 10c0/554847c8f2b6bfe0e634f317fc43d0b54771eea0015c4f844f75915fdb9e6170c830c004291bad57db949d61771732e459f36ed059f45cf750af223f77357c5c languageName: node linkType: hard @@ -844,90 +1067,90 @@ __metadata: languageName: node linkType: hard -"@napi-rs/canvas-android-arm64@npm:0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas-android-arm64@npm:0.1.68" +"@napi-rs/canvas-android-arm64@npm:0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas-android-arm64@npm:0.1.69" conditions: os=android & cpu=arm64 languageName: node linkType: hard -"@napi-rs/canvas-darwin-arm64@npm:0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas-darwin-arm64@npm:0.1.68" +"@napi-rs/canvas-darwin-arm64@npm:0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas-darwin-arm64@npm:0.1.69" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"@napi-rs/canvas-darwin-x64@npm:0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas-darwin-x64@npm:0.1.68" +"@napi-rs/canvas-darwin-x64@npm:0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas-darwin-x64@npm:0.1.69" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"@napi-rs/canvas-linux-arm-gnueabihf@npm:0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas-linux-arm-gnueabihf@npm:0.1.68" +"@napi-rs/canvas-linux-arm-gnueabihf@npm:0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas-linux-arm-gnueabihf@npm:0.1.69" conditions: os=linux & cpu=arm languageName: node linkType: hard -"@napi-rs/canvas-linux-arm64-gnu@npm:0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas-linux-arm64-gnu@npm:0.1.68" +"@napi-rs/canvas-linux-arm64-gnu@npm:0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas-linux-arm64-gnu@npm:0.1.69" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"@napi-rs/canvas-linux-arm64-musl@npm:0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas-linux-arm64-musl@npm:0.1.68" +"@napi-rs/canvas-linux-arm64-musl@npm:0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas-linux-arm64-musl@npm:0.1.69" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"@napi-rs/canvas-linux-riscv64-gnu@npm:0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas-linux-riscv64-gnu@npm:0.1.68" +"@napi-rs/canvas-linux-riscv64-gnu@npm:0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas-linux-riscv64-gnu@npm:0.1.69" conditions: os=linux & cpu=riscv64 & libc=glibc languageName: node linkType: hard -"@napi-rs/canvas-linux-x64-gnu@npm:0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas-linux-x64-gnu@npm:0.1.68" +"@napi-rs/canvas-linux-x64-gnu@npm:0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas-linux-x64-gnu@npm:0.1.69" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"@napi-rs/canvas-linux-x64-musl@npm:0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas-linux-x64-musl@npm:0.1.68" +"@napi-rs/canvas-linux-x64-musl@npm:0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas-linux-x64-musl@npm:0.1.69" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"@napi-rs/canvas-win32-x64-msvc@npm:0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas-win32-x64-msvc@npm:0.1.68" +"@napi-rs/canvas-win32-x64-msvc@npm:0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas-win32-x64-msvc@npm:0.1.69" conditions: os=win32 & cpu=x64 languageName: node linkType: hard -"@napi-rs/canvas@npm:^0.1.68": - version: 0.1.68 - resolution: "@napi-rs/canvas@npm:0.1.68" +"@napi-rs/canvas@npm:^0.1.69": + version: 0.1.69 + resolution: "@napi-rs/canvas@npm:0.1.69" dependencies: - "@napi-rs/canvas-android-arm64": "npm:0.1.68" - "@napi-rs/canvas-darwin-arm64": "npm:0.1.68" - "@napi-rs/canvas-darwin-x64": "npm:0.1.68" - "@napi-rs/canvas-linux-arm-gnueabihf": "npm:0.1.68" - "@napi-rs/canvas-linux-arm64-gnu": "npm:0.1.68" - "@napi-rs/canvas-linux-arm64-musl": "npm:0.1.68" - "@napi-rs/canvas-linux-riscv64-gnu": "npm:0.1.68" - "@napi-rs/canvas-linux-x64-gnu": "npm:0.1.68" - "@napi-rs/canvas-linux-x64-musl": "npm:0.1.68" - "@napi-rs/canvas-win32-x64-msvc": "npm:0.1.68" + "@napi-rs/canvas-android-arm64": "npm:0.1.69" + "@napi-rs/canvas-darwin-arm64": "npm:0.1.69" + "@napi-rs/canvas-darwin-x64": "npm:0.1.69" + "@napi-rs/canvas-linux-arm-gnueabihf": "npm:0.1.69" + "@napi-rs/canvas-linux-arm64-gnu": "npm:0.1.69" + "@napi-rs/canvas-linux-arm64-musl": "npm:0.1.69" + "@napi-rs/canvas-linux-riscv64-gnu": "npm:0.1.69" + "@napi-rs/canvas-linux-x64-gnu": "npm:0.1.69" + "@napi-rs/canvas-linux-x64-musl": "npm:0.1.69" + "@napi-rs/canvas-win32-x64-msvc": "npm:0.1.69" dependenciesMeta: "@napi-rs/canvas-android-arm64": optional: true @@ -949,7 +1172,7 @@ __metadata: optional: true "@napi-rs/canvas-win32-x64-msvc": optional: true - checksum: 10c0/9d9d3ade7d85840af3b48988b0570bdd7116091f24560ef05c14627a393b3f7b85c86a5f833be3d3c1d518948ab07d305db64630c7a9546ba0a5b2f0df3c23c5 + checksum: 10c0/ed924773fb5e5ddfb68c1c05b81d6b5dac345a32ece670f5b267d80e2878a66b86e4d6688b3fbd1c93de195cdd4148603144908a79793894a8b7c6ff5c8b4b9a languageName: node linkType: hard @@ -1002,13 +1225,6 @@ __metadata: languageName: node linkType: hard -"@petamoriken/float16@npm:^3.8.7": - version: 3.9.1 - resolution: "@petamoriken/float16@npm:3.9.1" - checksum: 10c0/e67ad03b18b7706747be7a5e73fa7ed7b67a69a45bb5a9a200a2e643c915352eac7b38ac6b9afb12883a854ce55f7bd008de066375f3ada7adc6e746b83cf3be - languageName: node - linkType: hard - "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -1068,6 +1284,15 @@ __metadata: languageName: node linkType: hard +"@types/conventional-commits-parser@npm:^5.0.0": + version: 5.0.1 + resolution: "@types/conventional-commits-parser@npm:5.0.1" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/4b7b561f195f779d07f973801a9f15d77cd58ceb67e817459688b11cc735288d30de050f445c91f4cd2c007fa86824e59a6e3cde602d150b828c4474f6e67be5 + languageName: node + linkType: hard + "@types/estree@npm:^1.0.6": version: 1.0.6 resolution: "@types/estree@npm:1.0.6" @@ -1091,23 +1316,23 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^22.14.0": - version: 22.14.0 - resolution: "@types/node@npm:22.14.0" +"@types/node@npm:^22.15.3": + version: 22.15.3 + resolution: "@types/node@npm:22.15.3" dependencies: undici-types: "npm:~6.21.0" - checksum: 10c0/9d79f3fa1af9c2c869514f419c4a4905b34c10e12915582fd1784868ac4e74c6d306cf5eb47ef889b6750ab85a31be96618227b86739c4a3e0b1c15063f384c6 + checksum: 10c0/2879f012d1aeba0bfdb5fed80d165f4f2cb3d1f2e1f98a24b18d4a211b4ace7d64bf2622784c78355982ffc1081ba79d0934efc2fb8353913e5871a63609661f languageName: node linkType: hard -"@types/pg@npm:^8.11.11": - version: 8.11.11 - resolution: "@types/pg@npm:8.11.11" +"@types/pg@npm:^8.11.14": + version: 8.11.14 + resolution: "@types/pg@npm:8.11.14" dependencies: "@types/node": "npm:*" pg-protocol: "npm:*" pg-types: "npm:^4.0.1" - checksum: 10c0/18c2585e1ba7a5dd5f849d49410d53fdfe9a6c3cbc4ae46c51fd728264d6ecf9a84a5cd82d89cb1f870a74383bad88effce1eed888f16accbcbde56a53d23a69 + checksum: 10c0/ad9be5f0215a337409d843b844c21af9a0073485125f32e91b1c19a3be233c7c8bfe641c761e91228a4b10e803f1ba4d3c0ed55dcd0ca1dd4f3a07ebd798347c languageName: node linkType: hard @@ -1120,15 +1345,15 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:^8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.29.0" +"@typescript-eslint/eslint-plugin@npm:^8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/eslint-plugin@npm:8.31.1" dependencies: "@eslint-community/regexpp": "npm:^4.10.0" - "@typescript-eslint/scope-manager": "npm:8.29.0" - "@typescript-eslint/type-utils": "npm:8.29.0" - "@typescript-eslint/utils": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" + "@typescript-eslint/scope-manager": "npm:8.31.1" + "@typescript-eslint/type-utils": "npm:8.31.1" + "@typescript-eslint/utils": "npm:8.31.1" + "@typescript-eslint/visitor-keys": "npm:8.31.1" graphemer: "npm:^1.4.0" ignore: "npm:^5.3.1" natural-compare: "npm:^1.4.0" @@ -1137,64 +1362,64 @@ __metadata: "@typescript-eslint/parser": ^8.0.0 || ^8.0.0-alpha.0 eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/3b05a7376a0752484229369af6eb60d13cf06d2331e79c2d471cb0b963dcf5b25a31617cafdc71d74e98921091f479f3da10725b761878a89421191d23866b54 + checksum: 10c0/9d805ab413a666fd2eefb16f257fbf3cea7278ccaf0db30ceb686dfe696e4f40b3aa7c336261c7f0a39a51a7c32a4f08d3d4f16bba0e764ac12c93ae94d82896 languageName: node linkType: hard -"@typescript-eslint/parser@npm:^8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/parser@npm:8.29.0" +"@typescript-eslint/parser@npm:^8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/parser@npm:8.31.1" dependencies: - "@typescript-eslint/scope-manager": "npm:8.29.0" - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/typescript-estree": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" + "@typescript-eslint/scope-manager": "npm:8.31.1" + "@typescript-eslint/types": "npm:8.31.1" + "@typescript-eslint/typescript-estree": "npm:8.31.1" + "@typescript-eslint/visitor-keys": "npm:8.31.1" debug: "npm:^4.3.4" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/270b9b27e3edb54b2f98a97b7f026f95580214002a04167809b1b71233c36d1585041cdcf26f9e08f48b9eb92652f4f9c756a83dc230f48fd204a4e5e054831c + checksum: 10c0/4fffaddbe443fc6a512042b6a777a8b7d9775938b26f54d86279b232b9b3967d90d6bfd65aca0ff010d377855df19708c918545f51cedc51b1688726201added languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/scope-manager@npm:8.29.0" +"@typescript-eslint/scope-manager@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/scope-manager@npm:8.31.1" dependencies: - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" - checksum: 10c0/330d777043a99485b51197ad24927f1276d61e61adaf710f012b3fe7db2ab67c8925c0526f801715b498e7d8fa7cef390006b6f7ae40cee89abe22e8e438de9a + "@typescript-eslint/types": "npm:8.31.1" + "@typescript-eslint/visitor-keys": "npm:8.31.1" + checksum: 10c0/759cfaa922f8bc97ecdcfe583df88ad31b04d02a865efc2c6dab622374c9f32839054596193ec3b1c478d8a73690999cbd996e1092605f41a54bbe6a9a62bbf3 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/type-utils@npm:8.29.0" +"@typescript-eslint/type-utils@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/type-utils@npm:8.31.1" dependencies: - "@typescript-eslint/typescript-estree": "npm:8.29.0" - "@typescript-eslint/utils": "npm:8.29.0" + "@typescript-eslint/typescript-estree": "npm:8.31.1" + "@typescript-eslint/utils": "npm:8.31.1" debug: "npm:^4.3.4" ts-api-utils: "npm:^2.0.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/f254c8d22fc29ea088d16b06e4be1756cb5ad05ccf5989e163637b9c582e5fc8c27aa7003aad6e6a8158d56d7711ea1e4e9d361702c1a18945e0e6aaff1267c5 + checksum: 10c0/ea5369cf200cd48f26e2c6013c81f5915cc933117e011537a7424402a1ebececc8a39e290b9572a7876a237116fbd75e9ba9313c9898ab828f5a814ab26066d2 languageName: node linkType: hard -"@typescript-eslint/types@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/types@npm:8.29.0" - checksum: 10c0/fc1e3f3071102973a9cfb8ae843c51398bd74b5583b7b0edad182ea605ef85e72ceac7987513581869958b3a65303af6b3471bfba5b7be1338e8add62019c858 +"@typescript-eslint/types@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/types@npm:8.31.1" + checksum: 10c0/d52692559028b71d8bfda4f098c7fa08e272c11cf9dd99ea9e1cfb00036c0849d6d53694e047a942c6568b3bf5637512e46356de70b412a9216ec6cfb8b2b950 languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.29.0" +"@typescript-eslint/typescript-estree@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/typescript-estree@npm:8.31.1" dependencies: - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/visitor-keys": "npm:8.29.0" + "@typescript-eslint/types": "npm:8.31.1" + "@typescript-eslint/visitor-keys": "npm:8.31.1" debug: "npm:^4.3.4" fast-glob: "npm:^3.3.2" is-glob: "npm:^4.0.3" @@ -1203,32 +1428,32 @@ __metadata: ts-api-utils: "npm:^2.0.1" peerDependencies: typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/61dd52229a0758e0bd29f732115c16e640a2418fb25488877c74ef03cdbeb43ddc592a37094abd794ef49812f33d6f814c5b662b95ea796ed0a6c6bfc849299b + checksum: 10c0/77059f204389d2d1b6db32d4df63473c99f5bd051218200f257531c2d2b2e3f237b23aa80a79baebc9ca8a776636867f1fd2d03533d207da2685d740e2c7fbef languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/utils@npm:8.29.0" +"@typescript-eslint/utils@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/utils@npm:8.31.1" dependencies: "@eslint-community/eslint-utils": "npm:^4.4.0" - "@typescript-eslint/scope-manager": "npm:8.29.0" - "@typescript-eslint/types": "npm:8.29.0" - "@typescript-eslint/typescript-estree": "npm:8.29.0" + "@typescript-eslint/scope-manager": "npm:8.31.1" + "@typescript-eslint/types": "npm:8.31.1" + "@typescript-eslint/typescript-estree": "npm:8.31.1" peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <5.9.0" - checksum: 10c0/e259d7edd12946b2dc8e1aa3bbea10f66c5277f27dda71368aa2b2923487f28cd1c123681aaae22518a31c8aeabd60a5365f8a832d0f6e6efadb03745a2d8a31 + checksum: 10c0/6190551702605aa60e67828163cb5880eee7ab5f1ee789d32227e4f4297d80ea9be98776400fd0660551dcbcac2a35babef33dd94267856dcb6f36c9c94f11ab languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.29.0": - version: 8.29.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.29.0" +"@typescript-eslint/visitor-keys@npm:8.31.1": + version: 8.31.1 + resolution: "@typescript-eslint/visitor-keys@npm:8.31.1" dependencies: - "@typescript-eslint/types": "npm:8.29.0" + "@typescript-eslint/types": "npm:8.31.1" eslint-visitor-keys: "npm:^4.2.0" - checksum: 10c0/7f5452b137c4edd258b2289cddf5d92687780375db33421bc4f5e2e9b0c94064c7c5ed3a7b5d96dc9c2d09ca7842b4415b3f3ed3e3f1ae3ac2e625ecb5e87efc + checksum: 10c0/09dbd8e1fdff72802a10bae2c12fa6d25f7e2dab1ff9b720afc2eb4e848b723c179109032aeaeb409d0c9e4107ab4fab8c8b1b47a55d58713d3f29a1365db3ea languageName: node linkType: hard @@ -1246,6 +1471,18 @@ __metadata: languageName: node linkType: hard +"JSONStream@npm:^1.3.5": + version: 1.3.5 + resolution: "JSONStream@npm:1.3.5" + dependencies: + jsonparse: "npm:^1.2.0" + through: "npm:>=2.2.7 <3" + bin: + JSONStream: ./bin.js + checksum: 10c0/0f54694da32224d57b715385d4a6b668d2117379d1f3223dc758459246cca58fdc4c628b83e8a8883334e454a0a30aa198ede77c788b55537c1844f686a751f2 + languageName: node + linkType: hard + "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -1320,6 +1557,27 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.11.0": + version: 8.17.1 + resolution: "ajv@npm:8.17.1" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10c0/ec3ba10a573c6b60f94639ffc53526275917a2df6810e4ab5a6b959d87459f9ef3f00d5e7865b82677cb7d21590355b34da14d1d0b9c32d75f95a187e76fff35 + languageName: node + linkType: hard + +"ansi-escapes@npm:^7.0.0": + version: 7.0.0 + resolution: "ansi-escapes@npm:7.0.0" + dependencies: + environment: "npm:^1.0.0" + checksum: 10c0/86e51e36fabef18c9c004af0a280573e828900641cea35134a124d2715e0c5a473494ab4ce396614505da77638ae290ff72dd8002d9747d2ee53f5d6bbe336be + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -1343,7 +1601,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^6.1.0": +"ansi-styles@npm:^6.0.0, ansi-styles@npm:^6.1.0, ansi-styles@npm:^6.2.1": version: 6.2.1 resolution: "ansi-styles@npm:6.2.1" checksum: 10c0/5d1ec38c123984bcedd996eac680d548f31828bd679a66db2bdf11844634dde55fec3efa9c6bb1d89056a5e79c1ac540c4c784d592ea1d25028a92227d2f2d5c @@ -1364,6 +1622,13 @@ __metadata: languageName: node linkType: hard +"array-ify@npm:^1.0.0": + version: 1.0.0 + resolution: "array-ify@npm:1.0.0" + checksum: 10c0/75c9c072faac47bd61779c0c595e912fe660d338504ac70d10e39e1b8a4a0c9c87658703d619b9d1b70d324177ae29dc8d07dda0d0a15d005597bc4c5a59c70c + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -1433,7 +1698,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0": +"chalk@npm:^4.0.0, chalk@npm:^4.1.2": version: 4.1.2 resolution: "chalk@npm:4.1.2" dependencies: @@ -1443,6 +1708,13 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^5.3.0, chalk@npm:^5.4.1": + version: 5.4.1 + resolution: "chalk@npm:5.4.1" + checksum: 10c0/b23e88132c702f4855ca6d25cb5538b1114343e41472d5263ee8a37cccfccd9c4216d111e1097c6a27830407a1dc81fecdf2a56f2c63033d4dbbd88c10b0dcef + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -1457,6 +1729,36 @@ __metadata: languageName: node linkType: hard +"cli-cursor@npm:^5.0.0": + version: 5.0.0 + resolution: "cli-cursor@npm:5.0.0" + dependencies: + restore-cursor: "npm:^5.0.0" + checksum: 10c0/7ec62f69b79f6734ab209a3e4dbdc8af7422d44d360a7cb1efa8a0887bbe466a6e625650c466fe4359aee44dbe2dc0b6994b583d40a05d0808a5cb193641d220 + languageName: node + linkType: hard + +"cli-truncate@npm:^4.0.0": + version: 4.0.0 + resolution: "cli-truncate@npm:4.0.0" + dependencies: + slice-ansi: "npm:^5.0.0" + string-width: "npm:^7.0.0" + checksum: 10c0/d7f0b73e3d9b88cb496e6c086df7410b541b56a43d18ade6a573c9c18bd001b1c3fba1ad578f741a4218fdc794d042385f8ac02c25e1c295a2d8b9f3cb86eb4c + languageName: node + linkType: hard + +"cliui@npm:^8.0.1": + version: 8.0.1 + resolution: "cliui@npm:8.0.1" + dependencies: + string-width: "npm:^4.2.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^7.0.0" + checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5 + languageName: node + linkType: hard + "cluster-key-slot@npm:^1.1.0": version: 1.1.2 resolution: "cluster-key-slot@npm:1.1.2" @@ -1480,6 +1782,30 @@ __metadata: languageName: node linkType: hard +"colorette@npm:^2.0.20": + version: 2.0.20 + resolution: "colorette@npm:2.0.20" + checksum: 10c0/e94116ff33b0ff56f3b83b9ace895e5bf87c2a7a47b3401b8c3f3226e050d5ef76cf4072fb3325f9dc24d1698f9b730baf4e05eeaf861d74a1883073f4c98a40 + languageName: node + linkType: hard + +"commander@npm:^13.1.0": + version: 13.1.0 + resolution: "commander@npm:13.1.0" + checksum: 10c0/7b8c5544bba704fbe84b7cab2e043df8586d5c114a4c5b607f83ae5060708940ed0b5bd5838cf8ce27539cde265c1cbd59ce3c8c6b017ed3eec8943e3a415164 + languageName: node + linkType: hard + +"compare-func@npm:^2.0.0": + version: 2.0.0 + resolution: "compare-func@npm:2.0.0" + dependencies: + array-ify: "npm:^1.0.0" + dot-prop: "npm:^5.1.0" + checksum: 10c0/78bd4dd4ed311a79bd264c9e13c36ed564cde657f1390e699e0f04b8eee1fc06ffb8698ce2dfb5fbe7342d509579c82d4e248f08915b708f77f7b72234086cc3 + languageName: node + linkType: hard + "concat-map@npm:0.0.1": version: 0.0.1 resolution: "concat-map@npm:0.0.1" @@ -1487,6 +1813,68 @@ __metadata: languageName: node linkType: hard +"conventional-changelog-angular@npm:^7.0.0": + version: 7.0.0 + resolution: "conventional-changelog-angular@npm:7.0.0" + dependencies: + compare-func: "npm:^2.0.0" + checksum: 10c0/90e73e25e224059b02951b6703b5f8742dc2a82c1fea62163978e6735fd3ab04350897a8fc6f443ec6b672d6b66e28a0820e833e544a0101f38879e5e6289b7e + languageName: node + linkType: hard + +"conventional-changelog-conventionalcommits@npm:^7.0.2": + version: 7.0.2 + resolution: "conventional-changelog-conventionalcommits@npm:7.0.2" + dependencies: + compare-func: "npm:^2.0.0" + checksum: 10c0/3cb1eab35e37fc973cfb3aed0e159f54414e49b222988da1c2aa86cc8a87fe7531491bbb7657fe5fc4dc0e25f5b50e2065ba8ac71cc4c08eed9189102a2b81bd + languageName: node + linkType: hard + +"conventional-commits-parser@npm:^5.0.0": + version: 5.0.0 + resolution: "conventional-commits-parser@npm:5.0.0" + dependencies: + JSONStream: "npm:^1.3.5" + is-text-path: "npm:^2.0.0" + meow: "npm:^12.0.1" + split2: "npm:^4.0.0" + bin: + conventional-commits-parser: cli.mjs + checksum: 10c0/c9e542f4884119a96a6bf3311ff62cdee55762d8547f4c745ae3ebdc50afe4ba7691e165e34827d5cf63283cbd93ab69917afd7922423075b123d5d9a7a82ed2 + languageName: node + linkType: hard + +"cosmiconfig-typescript-loader@npm:^6.1.0": + version: 6.1.0 + resolution: "cosmiconfig-typescript-loader@npm:6.1.0" + dependencies: + jiti: "npm:^2.4.1" + peerDependencies: + "@types/node": "*" + cosmiconfig: ">=9" + typescript: ">=5" + checksum: 10c0/5e3baf85a9da7dcdd7ef53a54d1293400eed76baf0abb3a41bf9fcc789f1a2653319443471f9a1dc32951f1de4467a6696ccd0f88640e7827f1af6ff94ceaf1a + languageName: node + linkType: hard + +"cosmiconfig@npm:^9.0.0": + version: 9.0.0 + resolution: "cosmiconfig@npm:9.0.0" + dependencies: + env-paths: "npm:^2.2.1" + import-fresh: "npm:^3.3.0" + js-yaml: "npm:^4.1.0" + parse-json: "npm:^5.2.0" + peerDependencies: + typescript: ">=4.9.5" + peerDependenciesMeta: + typescript: + optional: true + checksum: 10c0/1c1703be4f02a250b1d6ca3267e408ce16abfe8364193891afc94c2d5c060b69611fdc8d97af74b7e6d5d1aac0ab2fb94d6b079573146bc2d756c2484ce5f0ee + languageName: node + linkType: hard + "create-require@npm:^1.1.0": version: 1.1.1 resolution: "create-require@npm:1.1.1" @@ -1494,7 +1882,19 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.6": +"cross-env@npm:^7.0.3": + version: 7.0.3 + resolution: "cross-env@npm:7.0.3" + dependencies: + cross-spawn: "npm:^7.0.1" + bin: + cross-env: src/bin/cross-env.js + cross-env-shell: src/bin/cross-env-shell.js + checksum: 10c0/f3765c25746c69fcca369655c442c6c886e54ccf3ab8c16847d5ad0e91e2f337d36eedc6599c1227904bf2a228d721e690324446876115bc8e7b32a866735ecf + languageName: node + linkType: hard + +"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.1, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -1505,6 +1905,13 @@ __metadata: languageName: node linkType: hard +"dargs@npm:^8.0.0": + version: 8.1.0 + resolution: "dargs@npm:8.1.0" + checksum: 10c0/08cbd1ee4ac1a16fb7700e761af2e3e22d1bdc04ac4f851926f552dde8f9e57714c0d04013c2cca1cda0cba8fb637e0f93ad15d5285547a939dd1989ee06a82d + languageName: node + linkType: hard + "debug@npm:4, debug@npm:^4.3.4": version: 4.3.7 resolution: "debug@npm:4.3.7" @@ -1529,6 +1936,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.4.0": + version: 4.4.0 + resolution: "debug@npm:4.4.0" + dependencies: + ms: "npm:^2.1.3" + peerDependenciesMeta: + supports-color: + optional: true + checksum: 10c0/db94f1a182bf886f57b4755f85b3a74c39b5114b9377b7ab375dc2cfa3454f09490cc6c30f829df3fc8042bc8b8995f6567ce5cd96f3bc3688bd24027197d9de + languageName: node + linkType: hard + "deep-is@npm:^0.1.3": version: 0.1.4 resolution: "deep-is@npm:0.1.4" @@ -1550,37 +1969,31 @@ __metadata: languageName: node linkType: hard -"discord-api-types@npm:^0.37.114": - version: 0.37.115 - resolution: "discord-api-types@npm:0.37.115" - checksum: 10c0/71ea6a95b771b579b487e3a5f16302c4bd90daa496d4890dbde56b9744064aa1b3d709f3a50771ec57154f345b6cde18a5eb3b5b8be1f30a3f1b37b34b82b50a +"discord-api-types@npm:^0.38.1": + version: 0.38.1 + resolution: "discord-api-types@npm:0.38.1" + checksum: 10c0/8616eb025ccc998d1c5eca08fb50ba2faefbd53ed786ea84ded9b9f881037104bac22c6f53db357c1d12660ea32375a507c3cce8cf36873382ace42ddc73ca4b languageName: node linkType: hard -"discord-api-types@npm:^0.37.119": - version: 0.37.119 - resolution: "discord-api-types@npm:0.37.119" - checksum: 10c0/ae38b5b164ec8c9dbcd114917b014e20f7e50d861b2ac99658d9b9e5e2567414b9f357dae1683d0c90db21c5a19b79318c9ac7420802ed18a783481c57054f6a - languageName: node - linkType: hard - -"discord.js@npm:^14.18.0": - version: 14.18.0 - resolution: "discord.js@npm:14.18.0" +"discord.js@npm:^14.19.2": + version: 14.19.2 + resolution: "discord.js@npm:14.19.2" dependencies: - "@discordjs/builders": "npm:^1.10.1" + "@discordjs/builders": "npm:^1.11.1" "@discordjs/collection": "npm:1.5.3" - "@discordjs/formatters": "npm:^0.6.0" - "@discordjs/rest": "npm:^2.4.3" + "@discordjs/formatters": "npm:^0.6.1" + "@discordjs/rest": "npm:^2.5.0" "@discordjs/util": "npm:^1.1.1" - "@discordjs/ws": "npm:^1.2.1" + "@discordjs/ws": "npm:^1.2.2" "@sapphire/snowflake": "npm:3.5.3" - discord-api-types: "npm:^0.37.119" + discord-api-types: "npm:^0.38.1" fast-deep-equal: "npm:3.1.3" lodash.snakecase: "npm:4.1.1" + magic-bytes.js: "npm:^1.10.0" tslib: "npm:^2.6.3" undici: "npm:6.21.1" - checksum: 10c0/e7ca2c30966aa95b54f743a20afae307f0ca1dd5599bc3b2f63b66c3792fed6ece60a9a1df66b5e89a57a3c1a7b225f8ebef51cfde3a94c7bec85890503788a3 + checksum: 10c0/b9b3379802caccade65977f8f06405eeed4337e6b1af76b6cb28935bad713ae0e647cf5d846c09a6e093ed0a576f6593f5622d94b8f6b57ecbf5645f21aaaaa7 languageName: node linkType: hard @@ -1593,24 +2006,32 @@ __metadata: languageName: node linkType: hard -"drizzle-kit@npm:^0.30.6": - version: 0.30.6 - resolution: "drizzle-kit@npm:0.30.6" +"dot-prop@npm:^5.1.0": + version: 5.3.0 + resolution: "dot-prop@npm:5.3.0" dependencies: - "@drizzle-team/brocli": "npm:^0.10.2" - "@esbuild-kit/esm-loader": "npm:^2.5.5" - esbuild: "npm:^0.19.7" - esbuild-register: "npm:^3.5.0" - gel: "npm:^2.0.0" - bin: - drizzle-kit: bin.cjs - checksum: 10c0/a210a8349654b2baf3767fe7b3d4cc90aaae52810437d97f70c4e9f17b37543a0e460f94c643dba23f512d2298772eb6485ecd26599ffcdef9ef5cf90c4885f1 + is-obj: "npm:^2.0.0" + checksum: 10c0/93f0d343ef87fe8869320e62f2459f7e70f49c6098d948cc47e060f4a3f827d0ad61e83cb82f2bd90cd5b9571b8d334289978a43c0f98fea4f0e99ee8faa0599 languageName: node linkType: hard -"drizzle-orm@npm:^0.41.0": - version: 0.41.0 - resolution: "drizzle-orm@npm:0.41.0" +"drizzle-kit@npm:^0.31.0": + version: 0.31.0 + resolution: "drizzle-kit@npm:0.31.0" + dependencies: + "@drizzle-team/brocli": "npm:^0.10.2" + "@esbuild-kit/esm-loader": "npm:^2.5.5" + esbuild: "npm:^0.25.2" + esbuild-register: "npm:^3.5.0" + bin: + drizzle-kit: bin.cjs + checksum: 10c0/592471addd20dee3bde564faaba794f2b66cefabe50ce05af4cbc19dd559eea1102c8bc15e3d966ecaa28f8cecfa9948438ebc6893064052ca81cfa56a6e9dc4 + languageName: node + linkType: hard + +"drizzle-orm@npm:^0.43.1": + version: 0.43.1 + resolution: "drizzle-orm@npm:0.43.1" peerDependencies: "@aws-sdk/client-rds-data": ">=3" "@cloudflare/workers-types": ">=4" @@ -1620,7 +2041,7 @@ __metadata: "@neondatabase/serverless": ">=0.10.0" "@op-engineering/op-sqlite": ">=2" "@opentelemetry/api": ^1.4.1 - "@planetscale/database": ">=1" + "@planetscale/database": ">=1.13" "@prisma/client": "*" "@tidbcloud/serverless": "*" "@types/better-sqlite3": "*" @@ -1696,7 +2117,7 @@ __metadata: optional: true sqlite3: optional: true - checksum: 10c0/4abc7ab2958a7862a9bdd149f3ec892ec05892e7cd2042f996b6e58c5504e51d50b408833ff5bdd6d44a4a71af4b4c280d281b01a0ce59acfc26a0117b6b6875 + checksum: 10c0/1d0eeb67f348c0697cc7a20b6316f88aa0d15e9c8251b744959fdba0fe407781d88e73d41ebddc95201f72236cc1601116c5beb0d9e2d524b736c359e1bd51d6 languageName: node linkType: hard @@ -1707,6 +2128,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^10.3.0": + version: 10.4.0 + resolution: "emoji-regex@npm:10.4.0" + checksum: 10c0/a3fcedfc58bfcce21a05a5f36a529d81e88d602100145fcca3dc6f795e3c8acc4fc18fe773fbf9b6d6e9371205edb3afa2668ec3473fa2aa7fd47d2a9d46482d + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -1730,17 +2158,17 @@ __metadata: languageName: node linkType: hard -"env-paths@npm:^2.2.0": +"env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" checksum: 10c0/285325677bf00e30845e330eec32894f5105529db97496ee3f598478e50f008c5352a41a30e5e72ec9de8a542b5a570b85699cd63bd2bc646dbcb9f311d83bc4 languageName: node linkType: hard -"env-paths@npm:^3.0.0": - version: 3.0.0 - resolution: "env-paths@npm:3.0.0" - checksum: 10c0/76dec878cee47f841103bacd7fae03283af16f0702dad65102ef0a556f310b98a377885e0f32943831eb08b5ab37842a323d02529f3dfd5d0a40ca71b01b435f +"environment@npm:^1.0.0": + version: 1.1.0 + resolution: "environment@npm:1.1.0" + checksum: 10c0/fb26434b0b581ab397039e51ff3c92b34924a98b2039dcb47e41b7bca577b9dbf134a8eadb364415c74464b682e2d3afe1a4c0eb9873dc44ea814c5d3103331d languageName: node linkType: hard @@ -1751,6 +2179,15 @@ __metadata: languageName: node linkType: hard +"error-ex@npm:^1.3.1": + version: 1.3.2 + resolution: "error-ex@npm:1.3.2" + dependencies: + is-arrayish: "npm:^0.2.1" + checksum: 10c0/ba827f89369b4c93382cfca5a264d059dfefdaa56ecc5e338ffa58a6471f5ed93b71a20add1d52290a4873d92381174382658c885ac1a2305f7baca363ce9cce + languageName: node + linkType: hard + "esbuild-register@npm:^3.5.0": version: 3.6.0 resolution: "esbuild-register@npm:3.6.0" @@ -1762,33 +2199,35 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.19.7": - version: 0.19.12 - resolution: "esbuild@npm:0.19.12" +"esbuild@npm:^0.25.2": + version: 0.25.2 + resolution: "esbuild@npm:0.25.2" dependencies: - "@esbuild/aix-ppc64": "npm:0.19.12" - "@esbuild/android-arm": "npm:0.19.12" - "@esbuild/android-arm64": "npm:0.19.12" - "@esbuild/android-x64": "npm:0.19.12" - "@esbuild/darwin-arm64": "npm:0.19.12" - "@esbuild/darwin-x64": "npm:0.19.12" - "@esbuild/freebsd-arm64": "npm:0.19.12" - "@esbuild/freebsd-x64": "npm:0.19.12" - "@esbuild/linux-arm": "npm:0.19.12" - "@esbuild/linux-arm64": "npm:0.19.12" - "@esbuild/linux-ia32": "npm:0.19.12" - "@esbuild/linux-loong64": "npm:0.19.12" - "@esbuild/linux-mips64el": "npm:0.19.12" - "@esbuild/linux-ppc64": "npm:0.19.12" - "@esbuild/linux-riscv64": "npm:0.19.12" - "@esbuild/linux-s390x": "npm:0.19.12" - "@esbuild/linux-x64": "npm:0.19.12" - "@esbuild/netbsd-x64": "npm:0.19.12" - "@esbuild/openbsd-x64": "npm:0.19.12" - "@esbuild/sunos-x64": "npm:0.19.12" - "@esbuild/win32-arm64": "npm:0.19.12" - "@esbuild/win32-ia32": "npm:0.19.12" - "@esbuild/win32-x64": "npm:0.19.12" + "@esbuild/aix-ppc64": "npm:0.25.2" + "@esbuild/android-arm": "npm:0.25.2" + "@esbuild/android-arm64": "npm:0.25.2" + "@esbuild/android-x64": "npm:0.25.2" + "@esbuild/darwin-arm64": "npm:0.25.2" + "@esbuild/darwin-x64": "npm:0.25.2" + "@esbuild/freebsd-arm64": "npm:0.25.2" + "@esbuild/freebsd-x64": "npm:0.25.2" + "@esbuild/linux-arm": "npm:0.25.2" + "@esbuild/linux-arm64": "npm:0.25.2" + "@esbuild/linux-ia32": "npm:0.25.2" + "@esbuild/linux-loong64": "npm:0.25.2" + "@esbuild/linux-mips64el": "npm:0.25.2" + "@esbuild/linux-ppc64": "npm:0.25.2" + "@esbuild/linux-riscv64": "npm:0.25.2" + "@esbuild/linux-s390x": "npm:0.25.2" + "@esbuild/linux-x64": "npm:0.25.2" + "@esbuild/netbsd-arm64": "npm:0.25.2" + "@esbuild/netbsd-x64": "npm:0.25.2" + "@esbuild/openbsd-arm64": "npm:0.25.2" + "@esbuild/openbsd-x64": "npm:0.25.2" + "@esbuild/sunos-x64": "npm:0.25.2" + "@esbuild/win32-arm64": "npm:0.25.2" + "@esbuild/win32-ia32": "npm:0.25.2" + "@esbuild/win32-x64": "npm:0.25.2" dependenciesMeta: "@esbuild/aix-ppc64": optional: true @@ -1824,8 +2263,12 @@ __metadata: optional: true "@esbuild/linux-x64": optional: true + "@esbuild/netbsd-arm64": + optional: true "@esbuild/netbsd-x64": optional: true + "@esbuild/openbsd-arm64": + optional: true "@esbuild/openbsd-x64": optional: true "@esbuild/sunos-x64": @@ -1838,7 +2281,7 @@ __metadata: optional: true bin: esbuild: bin/esbuild - checksum: 10c0/0f2d21ffe24ebead64843f87c3aebe2e703a5ed9feb086a0728b24907fac2eb9923e4a79857d3df9059c915739bd7a870dd667972eae325c67f478b592b8582d + checksum: 10c0/87ce0b78699c4d192b8cf7e9b688e9a0da10e6f58ff85a368bf3044ca1fa95626c98b769b5459352282e0065585b6f994a5e6699af5cccf9d31178960e2b58fd languageName: node linkType: hard @@ -2005,6 +2448,13 @@ __metadata: languageName: node linkType: hard +"escalade@npm:^3.1.1": + version: 3.2.0 + resolution: "escalade@npm:3.2.0" + checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65 + languageName: node + linkType: hard + "escape-string-regexp@npm:^4.0.0": version: 4.0.0 resolution: "escape-string-regexp@npm:4.0.0" @@ -2012,14 +2462,14 @@ __metadata: languageName: node linkType: hard -"eslint-config-prettier@npm:^10.1.1": - version: 10.1.1 - resolution: "eslint-config-prettier@npm:10.1.1" +"eslint-config-prettier@npm:^10.1.2": + version: 10.1.2 + resolution: "eslint-config-prettier@npm:10.1.2" peerDependencies: eslint: ">=7.0.0" bin: eslint-config-prettier: bin/cli.js - checksum: 10c0/3dbfdf6495dd62e2e1644ea9e8e978100dabcd8740fd264df1222d130001a1e8de05d6ed6c67d3a60727386a07507f067d1ca79af6d546910414beab19e7966e + checksum: 10c0/c22c8e29193cc8fd70becf1c2dd072513f2b3004a175c2a49404c79d1745ba4dc0edc2afd00d16b0e26d24f95813a0469e7445a25104aec218f6d84cdb1697e9 languageName: node linkType: hard @@ -2112,18 +2562,18 @@ __metadata: languageName: node linkType: hard -"eslint@npm:^9.23.0": - version: 9.23.0 - resolution: "eslint@npm:9.23.0" +"eslint@npm:^9.25.1": + version: 9.25.1 + resolution: "eslint@npm:9.25.1" dependencies: "@eslint-community/eslint-utils": "npm:^4.2.0" "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.19.2" - "@eslint/config-helpers": "npm:^0.2.0" - "@eslint/core": "npm:^0.12.0" + "@eslint/config-array": "npm:^0.20.0" + "@eslint/config-helpers": "npm:^0.2.1" + "@eslint/core": "npm:^0.13.0" "@eslint/eslintrc": "npm:^3.3.1" - "@eslint/js": "npm:9.23.0" - "@eslint/plugin-kit": "npm:^0.2.7" + "@eslint/js": "npm:9.25.1" + "@eslint/plugin-kit": "npm:^0.2.8" "@humanfs/node": "npm:^0.16.6" "@humanwhocodes/module-importer": "npm:^1.0.1" "@humanwhocodes/retry": "npm:^0.4.2" @@ -2158,7 +2608,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 10c0/9616c308dfa8d09db8ae51019c87d5d05933742214531b077bd6ab618baab3bec7938256c14dcad4dc47f5ba93feb0bc5e089f68799f076374ddea21b6a9be45 + checksum: 10c0/3bb1997ae994253d441e56aba2fc64a71b3b8dce32756de3dedae5e85416ba33eb07e19ede94a6fa8ce7ef3a0a3b0dd8b6836f41be46a3ab52e5345ad59a553f languageName: node linkType: hard @@ -2227,6 +2677,30 @@ __metadata: languageName: node linkType: hard +"eventemitter3@npm:^5.0.1": + version: 5.0.1 + resolution: "eventemitter3@npm:5.0.1" + checksum: 10c0/4ba5c00c506e6c786b4d6262cfbce90ddc14c10d4667e5c83ae993c9de88aa856033994dd2b35b83e8dc1170e224e66a319fa80adc4c32adcd2379bbc75da814 + languageName: node + linkType: hard + +"execa@npm:^8.0.1": + version: 8.0.1 + resolution: "execa@npm:8.0.1" + dependencies: + cross-spawn: "npm:^7.0.3" + get-stream: "npm:^8.0.1" + human-signals: "npm:^5.0.0" + is-stream: "npm:^3.0.0" + merge-stream: "npm:^2.0.0" + npm-run-path: "npm:^5.1.0" + onetime: "npm:^6.0.0" + signal-exit: "npm:^4.1.0" + strip-final-newline: "npm:^3.0.0" + checksum: 10c0/2c52d8775f5bf103ce8eec9c7ab3059909ba350a5164744e9947ed14a53f51687c040a250bda833f906d1283aa8803975b84e6c8f7a7c42f99dc8ef80250d1af + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -2268,6 +2742,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.0.6 + resolution: "fast-uri@npm:3.0.6" + checksum: 10c0/74a513c2af0584448aee71ce56005185f81239eab7a2343110e5bad50c39ad4fb19c5a6f99783ead1cac7ccaf3461a6034fda89fffa2b30b6d99b9f21c2f9d29 + languageName: node + linkType: hard + "fastq@npm:^1.6.0": version: 1.17.1 resolution: "fastq@npm:1.17.1" @@ -2314,6 +2795,17 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^7.0.0": + version: 7.0.0 + resolution: "find-up@npm:7.0.0" + dependencies: + locate-path: "npm:^7.2.0" + path-exists: "npm:^5.0.0" + unicorn-magic: "npm:^0.1.0" + checksum: 10c0/e6ee3e6154560bc0ab3bc3b7d1348b31513f9bdf49a5dd2e952495427d559fa48cdf33953e85a309a323898b43fa1bfbc8b80c880dfc16068384783034030008 + languageName: node + linkType: hard + "flat-cache@npm:^3.0.4": version: 3.2.0 resolution: "flat-cache@npm:3.2.0" @@ -2396,19 +2888,31 @@ __metadata: languageName: node linkType: hard -"gel@npm:^2.0.0": - version: 2.0.0 - resolution: "gel@npm:2.0.0" - dependencies: - "@petamoriken/float16": "npm:^3.8.7" - debug: "npm:^4.3.4" - env-paths: "npm:^3.0.0" - semver: "npm:^7.6.2" - shell-quote: "npm:^1.8.1" - which: "npm:^4.0.0" - bin: - gel: dist/cli.mjs - checksum: 10c0/a86367335d07937a3cb5b186a29a65c88d8e2aa4f31c103c998ab871e434207accb5369fd4e0cd301eba07a14a7cb72a4533a252ddc06aa1131efa1cdcee558e +"function-bind@npm:^1.1.2": + version: 1.1.2 + resolution: "function-bind@npm:1.1.2" + checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5 + languageName: node + linkType: hard + +"get-caller-file@npm:^2.0.5": + version: 2.0.5 + resolution: "get-caller-file@npm:2.0.5" + checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde + languageName: node + linkType: hard + +"get-east-asian-width@npm:^1.0.0": + version: 1.3.0 + resolution: "get-east-asian-width@npm:1.3.0" + checksum: 10c0/1a049ba697e0f9a4d5514c4623781c5246982bdb61082da6b5ae6c33d838e52ce6726407df285cdbb27ec1908b333cf2820989bd3e986e37bb20979437fdf34b + languageName: node + linkType: hard + +"get-stream@npm:^8.0.1": + version: 8.0.1 + resolution: "get-stream@npm:8.0.1" + checksum: 10c0/5c2181e98202b9dae0bb4a849979291043e5892eb40312b47f0c22b9414fc9b28a3b6063d2375705eb24abc41ecf97894d9a51f64ff021511b504477b27b4290 languageName: node linkType: hard @@ -2421,6 +2925,19 @@ __metadata: languageName: node linkType: hard +"git-raw-commits@npm:^4.0.0": + version: 4.0.0 + resolution: "git-raw-commits@npm:4.0.0" + dependencies: + dargs: "npm:^8.0.0" + meow: "npm:^12.0.1" + split2: "npm:^4.0.0" + bin: + git-raw-commits: cli.mjs + checksum: 10c0/ab51335d9e55692fce8e42788013dba7a7e7bf9f5bf0622c8cd7ddc9206489e66bb939563fca4edb3aa87477e2118f052702aad1933b13c6fa738af7f29884f0 + languageName: node + linkType: hard + "glob-parent@npm:^5.1.2": version: 5.1.2 resolution: "glob-parent@npm:5.1.2" @@ -2469,6 +2986,26 @@ __metadata: languageName: node linkType: hard +"global-directory@npm:^4.0.1": + version: 4.0.1 + resolution: "global-directory@npm:4.0.1" + dependencies: + ini: "npm:4.1.1" + checksum: 10c0/f9cbeef41db4876f94dd0bac1c1b4282a7de9c16350ecaaf83e7b2dd777b32704cc25beeb1170b5a63c42a2c9abfade74d46357fe0133e933218bc89e613d4b2 + languageName: node + linkType: hard + +"global-prefix@npm:^4.0.0": + version: 4.0.0 + resolution: "global-prefix@npm:4.0.0" + dependencies: + ini: "npm:^4.1.3" + kind-of: "npm:^6.0.3" + which: "npm:^4.0.0" + checksum: 10c0/a757bba494f0542a34e82716450506a076e769e05993a9739aea3bf27c3f710cd5635d0f4c1c242650c0dc133bf20a8e8fc9cfd3d1d1c371717218ef561f1ac4 + languageName: node + linkType: hard + "globals@npm:^13.19.0": version: 13.24.0 resolution: "globals@npm:13.24.0" @@ -2513,6 +3050,15 @@ __metadata: languageName: node linkType: hard +"hasown@npm:^2.0.2": + version: 2.0.2 + resolution: "hasown@npm:2.0.2" + dependencies: + function-bind: "npm:^1.1.2" + checksum: 10c0/3769d434703b8ac66b209a4cca0737519925bbdb61dd887f93a16372b14694c63ff4e797686d87c90f08168e81082248b9b028bad60d4da9e0d1148766f56eb9 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -2540,6 +3086,22 @@ __metadata: languageName: node linkType: hard +"human-signals@npm:^5.0.0": + version: 5.0.0 + resolution: "human-signals@npm:5.0.0" + checksum: 10c0/5a9359073fe17a8b58e5a085e9a39a950366d9f00217c4ff5878bd312e09d80f460536ea6a3f260b5943a01fe55c158d1cea3fc7bee3d0520aeef04f6d915c82 + languageName: node + linkType: hard + +"husky@npm:^9.1.7": + version: 9.1.7 + resolution: "husky@npm:9.1.7" + bin: + husky: bin.js + checksum: 10c0/35bb110a71086c48906aa7cd3ed4913fb913823715359d65e32e0b964cb1e255593b0ae8014a5005c66a68e6fa66c38dcfa8056dbbdfb8b0187c0ffe7ee3a58f + languageName: node + linkType: hard + "iconv-lite@npm:^0.6.2": version: 0.6.3 resolution: "iconv-lite@npm:0.6.3" @@ -2566,6 +3128,23 @@ __metadata: languageName: node linkType: hard +"import-fresh@npm:^3.3.0": + version: 3.3.1 + resolution: "import-fresh@npm:3.3.1" + dependencies: + parent-module: "npm:^1.0.0" + resolve-from: "npm:^4.0.0" + checksum: 10c0/bf8cc494872fef783249709385ae883b447e3eb09db0ebd15dcead7d9afe7224dad7bd7591c6b73b0b19b3c0f9640eb8ee884f01cfaf2887ab995b0b36a0cbec + languageName: node + linkType: hard + +"import-meta-resolve@npm:^4.0.0": + version: 4.1.0 + resolution: "import-meta-resolve@npm:4.1.0" + checksum: 10c0/42f3284b0460635ddf105c4ad99c6716099c3ce76702602290ad5cbbcd295700cbc04e4bdf47bacf9e3f1a4cec2e1ff887dabc20458bef398f9de22ddff45ef5 + languageName: node + linkType: hard + "imurmurhash@npm:^0.1.4": version: 0.1.4 resolution: "imurmurhash@npm:0.1.4" @@ -2597,9 +3176,23 @@ __metadata: languageName: node linkType: hard -"ioredis@npm:^5.6.0": - version: 5.6.0 - resolution: "ioredis@npm:5.6.0" +"ini@npm:4.1.1": + version: 4.1.1 + resolution: "ini@npm:4.1.1" + checksum: 10c0/7fddc8dfd3e63567d4fdd5d999d1bf8a8487f1479d0b34a1d01f28d391a9228d261e19abc38e1a6a1ceb3400c727204fce05725d5eb598dfcf2077a1e3afe211 + languageName: node + linkType: hard + +"ini@npm:^4.1.3": + version: 4.1.3 + resolution: "ini@npm:4.1.3" + checksum: 10c0/0d27eff094d5f3899dd7c00d0c04ea733ca03a8eb6f9406ce15daac1a81de022cb417d6eaff7e4342451ffa663389c565ffc68d6825eaf686bf003280b945764 + languageName: node + linkType: hard + +"ioredis@npm:^5.6.1": + version: 5.6.1 + resolution: "ioredis@npm:5.6.1" dependencies: "@ioredis/commands": "npm:^1.1.1" cluster-key-slot: "npm:^1.1.0" @@ -2610,7 +3203,7 @@ __metadata: redis-errors: "npm:^1.2.0" redis-parser: "npm:^3.0.0" standard-as-callback: "npm:^2.1.0" - checksum: 10c0/a885e5146640fc448706871290ef424ffa39af561f7ee3cf1590085209a509f85e99082bdaaf3cd32fa66758aea3fc2055d1109648ddca96fac4944bf2092c30 + checksum: 10c0/26ae49cf448e807e454a9bdea5a9dfdcf669e2fdbf2df341900a0fb693c5662fea7e39db3227ce8972d1bda0ba7da9b7410e5163b12d8878a579548d847220ac languageName: node linkType: hard @@ -2624,6 +3217,22 @@ __metadata: languageName: node linkType: hard +"is-arrayish@npm:^0.2.1": + version: 0.2.1 + resolution: "is-arrayish@npm:0.2.1" + checksum: 10c0/e7fb686a739068bb70f860b39b67afc62acc62e36bb61c5f965768abce1873b379c563e61dd2adad96ebb7edf6651111b385e490cf508378959b0ed4cac4e729 + languageName: node + linkType: hard + +"is-core-module@npm:^2.16.0": + version: 2.16.1 + resolution: "is-core-module@npm:2.16.1" + dependencies: + hasown: "npm:^2.0.2" + checksum: 10c0/898443c14780a577e807618aaae2b6f745c8538eca5c7bc11388a3f2dc6de82b9902bcc7eb74f07be672b11bbe82dd6a6edded44a00cb3d8f933d0459905eedd + languageName: node + linkType: hard + "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -2638,6 +3247,22 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^4.0.0": + version: 4.0.0 + resolution: "is-fullwidth-code-point@npm:4.0.0" + checksum: 10c0/df2a717e813567db0f659c306d61f2f804d480752526886954a2a3e2246c7745fd07a52b5fecf2b68caf0a6c79dcdace6166fdf29cc76ed9975cc334f0a018b8 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^5.0.0": + version: 5.0.0 + resolution: "is-fullwidth-code-point@npm:5.0.0" + dependencies: + get-east-asian-width: "npm:^1.0.0" + checksum: 10c0/cd591b27d43d76b05fa65ed03eddce57a16e1eca0b7797ff7255de97019bcaf0219acfc0c4f7af13319e13541f2a53c0ace476f442b13267b9a6a7568f2b65c8 + languageName: node + linkType: hard + "is-glob@npm:^4.0.0, is-glob@npm:^4.0.1, is-glob@npm:^4.0.3": version: 4.0.3 resolution: "is-glob@npm:4.0.3" @@ -2661,6 +3286,13 @@ __metadata: languageName: node linkType: hard +"is-obj@npm:^2.0.0": + version: 2.0.0 + resolution: "is-obj@npm:2.0.0" + checksum: 10c0/85044ed7ba8bd169e2c2af3a178cacb92a97aa75de9569d02efef7f443a824b5e153eba72b9ae3aca6f8ce81955271aa2dc7da67a8b720575d3e38104208cb4e + languageName: node + linkType: hard + "is-path-inside@npm:^3.0.3": version: 3.0.3 resolution: "is-path-inside@npm:3.0.3" @@ -2668,6 +3300,22 @@ __metadata: languageName: node linkType: hard +"is-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "is-stream@npm:3.0.0" + checksum: 10c0/eb2f7127af02ee9aa2a0237b730e47ac2de0d4e76a4a905a50a11557f2339df5765eaea4ceb8029f1efa978586abe776908720bfcb1900c20c6ec5145f6f29d8 + languageName: node + linkType: hard + +"is-text-path@npm:^2.0.0": + version: 2.0.0 + resolution: "is-text-path@npm:2.0.0" + dependencies: + text-extensions: "npm:^2.0.0" + checksum: 10c0/e3c470e1262a3a54aa0fca1c0300b2659a7aed155714be6b643f88822c03bcfa6659b491f7a05c5acd3c1a3d6d42bab47e1bdd35bcc3a25973c4f26b2928bc1a + languageName: node + linkType: hard + "isexe@npm:^2.0.0": version: 2.0.0 resolution: "isexe@npm:2.0.0" @@ -2695,6 +3343,22 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^2.4.1": + version: 2.4.2 + resolution: "jiti@npm:2.4.2" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/4ceac133a08c8faff7eac84aabb917e85e8257f5ad659e843004ce76e981c457c390a220881748ac67ba1b940b9b729b30fb85cbaf6e7989f04b6002c94da331 + languageName: node + linkType: hard + +"js-tokens@npm:^4.0.0": + version: 4.0.0 + resolution: "js-tokens@npm:4.0.0" + checksum: 10c0/e248708d377aa058eacf2037b07ded847790e6de892bbad3dac0abba2e759cb9f121b00099a65195616badcb6eca8d14d975cb3e89eb1cfda644756402c8aeed + languageName: node + linkType: hard + "js-yaml@npm:^4.1.0": version: 4.1.0 resolution: "js-yaml@npm:4.1.0" @@ -2727,6 +3391,13 @@ __metadata: languageName: node linkType: hard +"json-parse-even-better-errors@npm:^2.3.0": + version: 2.3.1 + resolution: "json-parse-even-better-errors@npm:2.3.1" + checksum: 10c0/140932564c8f0b88455432e0f33c4cb4086b8868e37524e07e723f4eaedb9425bdc2bafd71bd1d9765bd15fd1e2d126972bc83990f55c467168c228c24d665f3 + languageName: node + linkType: hard + "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -2734,6 +3405,13 @@ __metadata: languageName: node linkType: hard +"json-schema-traverse@npm:^1.0.0": + version: 1.0.0 + resolution: "json-schema-traverse@npm:1.0.0" + checksum: 10c0/71e30015d7f3d6dc1c316d6298047c8ef98a06d31ad064919976583eb61e1018a60a0067338f0f79cabc00d84af3fcc489bd48ce8a46ea165d9541ba17fb30c6 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -2741,6 +3419,13 @@ __metadata: languageName: node linkType: hard +"jsonparse@npm:^1.2.0": + version: 1.3.1 + resolution: "jsonparse@npm:1.3.1" + checksum: 10c0/89bc68080cd0a0e276d4b5ab1b79cacd68f562467008d176dc23e16e97d4efec9e21741d92ba5087a8433526a45a7e6a9d5ef25408696c402ca1cfbc01a90bf0 + languageName: node + linkType: hard + "keyv@npm:^4.5.3, keyv@npm:^4.5.4": version: 4.5.4 resolution: "keyv@npm:4.5.4" @@ -2750,6 +3435,13 @@ __metadata: languageName: node linkType: hard +"kind-of@npm:^6.0.3": + version: 6.0.3 + resolution: "kind-of@npm:6.0.3" + checksum: 10c0/61cdff9623dabf3568b6445e93e31376bee1cdb93f8ba7033d86022c2a9b1791a1d9510e026e6465ebd701a6dd2f7b0808483ad8838341ac52f003f512e0b4c4 + languageName: node + linkType: hard + "levn@npm:^0.4.1": version: 0.4.1 resolution: "levn@npm:0.4.1" @@ -2760,6 +3452,54 @@ __metadata: languageName: node linkType: hard +"lilconfig@npm:^3.1.3": + version: 3.1.3 + resolution: "lilconfig@npm:3.1.3" + checksum: 10c0/f5604e7240c5c275743561442fbc5abf2a84ad94da0f5adc71d25e31fa8483048de3dcedcb7a44112a942fed305fd75841cdf6c9681c7f640c63f1049e9a5dcc + languageName: node + linkType: hard + +"lines-and-columns@npm:^1.1.6": + version: 1.2.4 + resolution: "lines-and-columns@npm:1.2.4" + checksum: 10c0/3da6ee62d4cd9f03f5dc90b4df2540fb85b352081bee77fe4bbcd12c9000ead7f35e0a38b8d09a9bb99b13223446dd8689ff3c4959807620726d788701a83d2d + languageName: node + linkType: hard + +"lint-staged@npm:^15.5.1": + version: 15.5.1 + resolution: "lint-staged@npm:15.5.1" + dependencies: + chalk: "npm:^5.4.1" + commander: "npm:^13.1.0" + debug: "npm:^4.4.0" + execa: "npm:^8.0.1" + lilconfig: "npm:^3.1.3" + listr2: "npm:^8.2.5" + micromatch: "npm:^4.0.8" + pidtree: "npm:^0.6.0" + string-argv: "npm:^0.3.2" + yaml: "npm:^2.7.0" + bin: + lint-staged: bin/lint-staged.js + checksum: 10c0/86deddb08bf10428f2eb96c02326a9ee403360729225f0b12afb0c0f13c287a75daa01e179d86f64e3432576446d8643d204a47417296f9ef0aa56f1340ff2af + languageName: node + linkType: hard + +"listr2@npm:^8.2.5": + version: 8.2.5 + resolution: "listr2@npm:8.2.5" + dependencies: + cli-truncate: "npm:^4.0.0" + colorette: "npm:^2.0.20" + eventemitter3: "npm:^5.0.1" + log-update: "npm:^6.1.0" + rfdc: "npm:^1.4.1" + wrap-ansi: "npm:^9.0.0" + checksum: 10c0/f5a9599514b00c27d7eb32d1117c83c61394b2a985ec20e542c798bf91cf42b19340215701522736f5b7b42f557e544afeadec47866e35e5d4f268f552729671 + languageName: node + linkType: hard + "locate-path@npm:^6.0.0": version: 6.0.0 resolution: "locate-path@npm:6.0.0" @@ -2769,6 +3509,22 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^7.2.0": + version: 7.2.0 + resolution: "locate-path@npm:7.2.0" + dependencies: + p-locate: "npm:^6.0.0" + checksum: 10c0/139e8a7fe11cfbd7f20db03923cacfa5db9e14fa14887ea121345597472b4a63c1a42a8a5187defeeff6acf98fd568da7382aa39682d38f0af27433953a97751 + languageName: node + linkType: hard + +"lodash.camelcase@npm:^4.3.0": + version: 4.3.0 + resolution: "lodash.camelcase@npm:4.3.0" + checksum: 10c0/fcba15d21a458076dd309fce6b1b4bf611d84a0ec252cb92447c948c533ac250b95d2e00955801ebc367e5af5ed288b996d75d37d2035260a937008e14eaf432 + languageName: node + linkType: hard + "lodash.defaults@npm:^4.2.0": version: 4.2.0 resolution: "lodash.defaults@npm:4.2.0" @@ -2783,6 +3539,20 @@ __metadata: languageName: node linkType: hard +"lodash.isplainobject@npm:^4.0.6": + version: 4.0.6 + resolution: "lodash.isplainobject@npm:4.0.6" + checksum: 10c0/afd70b5c450d1e09f32a737bed06ff85b873ecd3d3d3400458725283e3f2e0bb6bf48e67dbe7a309eb371a822b16a26cca4a63c8c52db3fc7dc9d5f9dd324cbb + languageName: node + linkType: hard + +"lodash.kebabcase@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.kebabcase@npm:4.1.1" + checksum: 10c0/da5d8f41dbb5bc723d4bf9203d5096ca8da804d6aec3d2b56457156ba6c8d999ff448d347ebd97490da853cb36696ea4da09a431499f1ee8deb17b094ecf4e33 + languageName: node + linkType: hard + "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -2790,13 +3560,41 @@ __metadata: languageName: node linkType: hard -"lodash.snakecase@npm:4.1.1": +"lodash.mergewith@npm:^4.6.2": + version: 4.6.2 + resolution: "lodash.mergewith@npm:4.6.2" + checksum: 10c0/4adbed65ff96fd65b0b3861f6899f98304f90fd71e7f1eb36c1270e05d500ee7f5ec44c02ef979b5ddbf75c0a0b9b99c35f0ad58f4011934c4d4e99e5200b3b5 + languageName: node + linkType: hard + +"lodash.snakecase@npm:4.1.1, lodash.snakecase@npm:^4.1.1": version: 4.1.1 resolution: "lodash.snakecase@npm:4.1.1" checksum: 10c0/f0b3f2497eb20eea1a1cfc22d645ecaeb78ac14593eb0a40057977606d2f35f7aaff0913a06553c783b535aafc55b718f523f9eb78f8d5293f492af41002eaf9 languageName: node linkType: hard +"lodash.startcase@npm:^4.4.0": + version: 4.4.0 + resolution: "lodash.startcase@npm:4.4.0" + checksum: 10c0/bd82aa87a45de8080e1c5ee61128c7aee77bf7f1d86f4ff94f4a6d7438fc9e15e5f03374b947be577a93804c8ad6241f0251beaf1452bf716064eeb657b3a9f0 + languageName: node + linkType: hard + +"lodash.uniq@npm:^4.5.0": + version: 4.5.0 + resolution: "lodash.uniq@npm:4.5.0" + checksum: 10c0/262d400bb0952f112162a320cc4a75dea4f66078b9e7e3075ffbc9c6aa30b3e9df3cf20e7da7d566105e1ccf7804e4fbd7d804eee0b53de05d83f16ffbf41c5e + languageName: node + linkType: hard + +"lodash.upperfirst@npm:^4.3.1": + version: 4.3.1 + resolution: "lodash.upperfirst@npm:4.3.1" + checksum: 10c0/435625da4b3ee74e7a1367a780d9107ab0b13ef4359fc074b2a1a40458eb8d91b655af62f6795b7138d493303a98c0285340160341561d6896e4947e077fa975 + languageName: node + linkType: hard + "lodash@npm:^4.17.14, lodash@npm:^4.17.21": version: 4.17.21 resolution: "lodash@npm:4.17.21" @@ -2804,6 +3602,19 @@ __metadata: languageName: node linkType: hard +"log-update@npm:^6.1.0": + version: 6.1.0 + resolution: "log-update@npm:6.1.0" + dependencies: + ansi-escapes: "npm:^7.0.0" + cli-cursor: "npm:^5.0.0" + slice-ansi: "npm:^7.1.0" + strip-ansi: "npm:^7.1.0" + wrap-ansi: "npm:^9.0.0" + checksum: 10c0/4b350c0a83d7753fea34dcac6cd797d1dc9603291565de009baa4aa91c0447eab0d3815a05c8ec9ac04fdfffb43c82adcdb03ec1fceafd8518e1a8c1cff4ff89 + languageName: node + linkType: hard + "lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" @@ -2845,6 +3656,20 @@ __metadata: languageName: node linkType: hard +"meow@npm:^12.0.1": + version: 12.1.1 + resolution: "meow@npm:12.1.1" + checksum: 10c0/a125ca99a32e2306e2f4cbe651a0d27f6eb67918d43a075f6e80b35e9bf372ebf0fc3a9fbc201cbbc9516444b6265fb3c9f80c5b7ebd32f548aa93eb7c28e088 + languageName: node + linkType: hard + +"merge-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-stream@npm:2.0.0" + checksum: 10c0/867fdbb30a6d58b011449b8885601ec1690c3e41c759ecd5a9d609094f7aed0096c37823ff4a7190ef0b8f22cc86beb7049196ff68c016e3b3c671d0dac91ce5 + languageName: node + linkType: hard + "merge2@npm:^1.3.0": version: 1.4.1 resolution: "merge2@npm:1.4.1" @@ -2852,7 +3677,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.4": +"micromatch@npm:^4.0.4, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -2862,6 +3687,20 @@ __metadata: languageName: node linkType: hard +"mimic-fn@npm:^4.0.0": + version: 4.0.0 + resolution: "mimic-fn@npm:4.0.0" + checksum: 10c0/de9cc32be9996fd941e512248338e43407f63f6d497abe8441fa33447d922e927de54d4cc3c1a3c6d652857acd770389d5a3823f311a744132760ce2be15ccbf + languageName: node + linkType: hard + +"mimic-function@npm:^5.0.0": + version: 5.0.1 + resolution: "mimic-function@npm:5.0.1" + checksum: 10c0/f3d9464dd1816ecf6bdf2aec6ba32c0728022039d992f178237d8e289b48764fee4131319e72eedd4f7f094e22ded0af836c3187a7edc4595d28dd74368fd81d + languageName: node + linkType: hard + "minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -2871,7 +3710,7 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^9.0.4": +"minimatch@npm:^9.0.4, minimatch@npm:^9.0.5": version: 9.0.5 resolution: "minimatch@npm:9.0.5" dependencies: @@ -2880,6 +3719,13 @@ __metadata: languageName: node linkType: hard +"minimist@npm:^1.2.8": + version: 1.2.8 + resolution: "minimist@npm:1.2.8" + checksum: 10c0/19d3fcdca050087b84c2029841a093691a91259a47def2f18222f41e7645a0b7c44ef4b40e88a1e58a40c84d2ef0ee6047c55594d298146d0eb3f6b737c20ce6 + languageName: node + linkType: hard + "minipass-collect@npm:^2.0.1": version: 2.0.1 resolution: "minipass-collect@npm:2.0.1" @@ -3032,6 +3878,15 @@ __metadata: languageName: node linkType: hard +"npm-run-path@npm:^5.1.0": + version: 5.3.0 + resolution: "npm-run-path@npm:5.3.0" + dependencies: + path-key: "npm:^4.0.0" + checksum: 10c0/124df74820c40c2eb9a8612a254ea1d557ddfab1581c3e751f825e3e366d9f00b0d76a3c94ecd8398e7f3eee193018622677e95816e8491f0797b21e30b2deba + languageName: node + linkType: hard + "obuf@npm:~1.1.2": version: 1.1.2 resolution: "obuf@npm:1.1.2" @@ -3048,6 +3903,24 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^6.0.0": + version: 6.0.0 + resolution: "onetime@npm:6.0.0" + dependencies: + mimic-fn: "npm:^4.0.0" + checksum: 10c0/4eef7c6abfef697dd4479345a4100c382d73c149d2d56170a54a07418c50816937ad09500e1ed1e79d235989d073a9bade8557122aee24f0576ecde0f392bb6c + languageName: node + linkType: hard + +"onetime@npm:^7.0.0": + version: 7.0.0 + resolution: "onetime@npm:7.0.0" + dependencies: + mimic-function: "npm:^5.0.0" + checksum: 10c0/5cb9179d74b63f52a196a2e7037ba2b9a893245a5532d3f44360012005c9cadb60851d56716ebff18a6f47129dab7168022445df47c2aff3b276d92585ed1221 + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -3071,6 +3944,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:^4.0.0": + version: 4.0.0 + resolution: "p-limit@npm:4.0.0" + dependencies: + yocto-queue: "npm:^1.0.0" + checksum: 10c0/a56af34a77f8df2ff61ddfb29431044557fcbcb7642d5a3233143ebba805fc7306ac1d448de724352861cb99de934bc9ab74f0d16fe6a5460bdbdf938de875ad + languageName: node + linkType: hard + "p-locate@npm:^5.0.0": version: 5.0.0 resolution: "p-locate@npm:5.0.0" @@ -3080,6 +3962,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^6.0.0": + version: 6.0.0 + resolution: "p-locate@npm:6.0.0" + dependencies: + p-limit: "npm:^4.0.0" + checksum: 10c0/d72fa2f41adce59c198270aa4d3c832536c87a1806e0f69dffb7c1a7ca998fb053915ca833d90f166a8c082d3859eabfed95f01698a3214c20df6bb8de046312 + languageName: node + linkType: hard + "p-map@npm:^4.0.0": version: 4.0.0 resolution: "p-map@npm:4.0.0" @@ -3105,6 +3996,18 @@ __metadata: languageName: node linkType: hard +"parse-json@npm:^5.2.0": + version: 5.2.0 + resolution: "parse-json@npm:5.2.0" + dependencies: + "@babel/code-frame": "npm:^7.0.0" + error-ex: "npm:^1.3.1" + json-parse-even-better-errors: "npm:^2.3.0" + lines-and-columns: "npm:^1.1.6" + checksum: 10c0/77947f2253005be7a12d858aedbafa09c9ae39eb4863adf330f7b416ca4f4a08132e453e08de2db46459256fb66afaac5ee758b44fe6541b7cdaf9d252e59585 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -3112,6 +4015,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^5.0.0": + version: 5.0.0 + resolution: "path-exists@npm:5.0.0" + checksum: 10c0/b170f3060b31604cde93eefdb7392b89d832dfbc1bed717c9718cbe0f230c1669b7e75f87e19901da2250b84d092989a0f9e44d2ef41deb09aa3ad28e691a40a + languageName: node + linkType: hard + "path-is-absolute@npm:^1.0.0": version: 1.0.1 resolution: "path-is-absolute@npm:1.0.1" @@ -3126,6 +4036,20 @@ __metadata: languageName: node linkType: hard +"path-key@npm:^4.0.0": + version: 4.0.0 + resolution: "path-key@npm:4.0.0" + checksum: 10c0/794efeef32863a65ac312f3c0b0a99f921f3e827ff63afa5cb09a377e202c262b671f7b3832a4e64731003fa94af0263713962d317b9887bd1e0c48a342efba3 + languageName: node + linkType: hard + +"path-parse@npm:^1.0.7": + version: 1.0.7 + resolution: "path-parse@npm:1.0.7" + checksum: 10c0/11ce261f9d294cc7a58d6a574b7f1b935842355ec66fba3c3fd79e0f036462eaf07d0aa95bb74ff432f9afef97ce1926c720988c6a7451d8a584930ae7de86e1 + languageName: node + linkType: hard + "path-scurry@npm:^1.11.1": version: 1.11.1 resolution: "path-scurry@npm:1.11.1" @@ -3136,17 +4060,17 @@ __metadata: languageName: node linkType: hard -"pg-cloudflare@npm:^1.1.1": - version: 1.1.1 - resolution: "pg-cloudflare@npm:1.1.1" - checksum: 10c0/a68b957f755be6af813d68ccaf4c906a000fd2ecb362cd281220052cc9e2f6c26da3b88792742387008c30b3bf0d2fa3a0eff04aeb8af4414023c99ae78e07bd +"pg-cloudflare@npm:^1.2.5": + version: 1.2.5 + resolution: "pg-cloudflare@npm:1.2.5" + checksum: 10c0/48b9105ef027c7b3f57ef88ceaec3634cd82120059bd68273cce06989a1ec547e0b0fbb5d1afdd0711824f409c8b410f9bdec2f6c8034728992d3658c0b36f86 languageName: node linkType: hard -"pg-connection-string@npm:^2.7.0": - version: 2.7.0 - resolution: "pg-connection-string@npm:2.7.0" - checksum: 10c0/50a1496a1c858f9495d78a2c7a66d93ef3602e718aff2953bb5738f3ea616d7f727f32fc20513c9bed127650cd14c1ddc7b458396f4000e689d4b64c65c5c51e +"pg-connection-string@npm:^2.8.5": + version: 2.8.5 + resolution: "pg-connection-string@npm:2.8.5" + checksum: 10c0/5f65afc9dfc99ecf1583a1699c97511f3d505659c9c6a91db8cd0ffe862caa29060722712a034abd6da493356567261febf18b3a6ef223d0a219f0d50d959b97 languageName: node linkType: hard @@ -3164,12 +4088,12 @@ __metadata: languageName: node linkType: hard -"pg-pool@npm:^3.8.0": - version: 3.8.0 - resolution: "pg-pool@npm:3.8.0" +"pg-pool@npm:^3.9.6": + version: 3.9.6 + resolution: "pg-pool@npm:3.9.6" peerDependencies: pg: ">=8.0" - checksum: 10c0/c05287b0caafeab43807e6ad22d153c09c473dbeb5b2cea13b83102376e9a56f46b91fa9adf9d53885ce198280c6a95555390987c42b3858d1936d3e0cdc83aa + checksum: 10c0/458d50a4e7260977f076472d40d0796fa8b513af7e3ce1bf65557e10724e9c13653661c883f6650dff92d0a1a5ff4e7a001a8262b786c1ad4cfbd35c3354353e languageName: node linkType: hard @@ -3180,10 +4104,10 @@ __metadata: languageName: node linkType: hard -"pg-protocol@npm:^1.8.0": - version: 1.8.0 - resolution: "pg-protocol@npm:1.8.0" - checksum: 10c0/2be784955599d84b564795952cee52cc2b8eab0be43f74fc1061506353801e282c1d52c9e0691a9b72092c1f3fde370e9b181e80fef6bb82a9b8d1618bfa91e6 +"pg-protocol@npm:^1.9.5": + version: 1.9.5 + resolution: "pg-protocol@npm:1.9.5" + checksum: 10c0/5cb3444cf973adadd22ee9ea26bb1674f0d980ef8f9c66d426bbe67fc9cb5f0ca4a204bf7e432b3ab2ab59ac8227f4b18ab3b2e64eaed537e037e916991c7319 languageName: node linkType: hard @@ -3215,14 +4139,14 @@ __metadata: languageName: node linkType: hard -"pg@npm:^8.14.1": - version: 8.14.1 - resolution: "pg@npm:8.14.1" +"pg@npm:^8.15.6": + version: 8.15.6 + resolution: "pg@npm:8.15.6" dependencies: - pg-cloudflare: "npm:^1.1.1" - pg-connection-string: "npm:^2.7.0" - pg-pool: "npm:^3.8.0" - pg-protocol: "npm:^1.8.0" + pg-cloudflare: "npm:^1.2.5" + pg-connection-string: "npm:^2.8.5" + pg-pool: "npm:^3.9.6" + pg-protocol: "npm:^1.9.5" pg-types: "npm:^2.1.0" pgpass: "npm:1.x" peerDependencies: @@ -3233,7 +4157,7 @@ __metadata: peerDependenciesMeta: pg-native: optional: true - checksum: 10c0/221741cfcea4ab32c8b57bd60703bc36cfb5622dcac56c19e45f504ef8669f2f2e0429af8850f58079cfc89055da35b5a5e12de19e0505e3f61a4b4349388dcb + checksum: 10c0/d4dc81020ebd137b6cf6228e43c643067acb8240079a07f7b9a7e97be0f33ad4d8c6f2a3f5b512ad87180e3d48e651fbd72885aa807ab58a715da8f3efea0fab languageName: node linkType: hard @@ -3246,6 +4170,13 @@ __metadata: languageName: node linkType: hard +"picocolors@npm:^1.0.0": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + "picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" @@ -3253,30 +4184,46 @@ __metadata: languageName: node linkType: hard +"pidtree@npm:^0.6.0": + version: 0.6.0 + resolution: "pidtree@npm:0.6.0" + bin: + pidtree: bin/pidtree.js + checksum: 10c0/0829ec4e9209e230f74ebf4265f5ccc9ebfb488334b525cb13f86ff801dca44b362c41252cd43ae4d7653a10a5c6ab3be39d2c79064d6895e0d78dc50a5ed6e9 + languageName: node + linkType: hard + "poixpixel-discord-bot@workspace:.": version: 0.0.0-use.local resolution: "poixpixel-discord-bot@workspace:." dependencies: + "@commitlint/cli": "npm:^19.8.0" + "@commitlint/config-conventional": "npm:^19.8.0" "@eslint/eslintrc": "npm:^3.3.1" - "@eslint/js": "npm:^9.23.0" + "@eslint/js": "npm:^9.25.1" "@microsoft/eslint-formatter-sarif": "npm:^3.1.0" - "@napi-rs/canvas": "npm:^0.1.68" - "@types/node": "npm:^22.14.0" - "@types/pg": "npm:^8.11.11" - "@typescript-eslint/eslint-plugin": "npm:^8.29.0" - "@typescript-eslint/parser": "npm:^8.29.0" - discord.js: "npm:^14.18.0" - drizzle-kit: "npm:^0.30.6" - drizzle-orm: "npm:^0.41.0" - eslint: "npm:^9.23.0" - eslint-config-prettier: "npm:^10.1.1" + "@napi-rs/canvas": "npm:^0.1.69" + "@types/node": "npm:^22.15.3" + "@types/pg": "npm:^8.11.14" + "@typescript-eslint/eslint-plugin": "npm:^8.31.1" + "@typescript-eslint/parser": "npm:^8.31.1" + cross-env: "npm:^7.0.3" + discord.js: "npm:^14.19.2" + drizzle-kit: "npm:^0.31.0" + drizzle-orm: "npm:^0.43.1" + eslint: "npm:^9.25.1" + eslint-config-prettier: "npm:^10.1.2" globals: "npm:^16.0.0" - ioredis: "npm:^5.6.0" - pg: "npm:^8.14.1" + husky: "npm:^9.1.7" + ioredis: "npm:^5.6.1" + lint-staged: "npm:^15.5.1" + pg: "npm:^8.15.6" prettier: "npm:3.5.3" ts-node: "npm:^10.9.2" - tsx: "npm:^4.19.3" - typescript: "npm:^5.8.2" + ts-patch: "npm:^3.3.0" + tsx: "npm:^4.19.4" + typescript: "npm:^5.8.3" + typescript-transform-paths: "npm:^3.5.5" languageName: unknown linkType: soft @@ -3410,6 +4357,20 @@ __metadata: languageName: node linkType: hard +"require-directory@npm:^2.1.1": + version: 2.1.1 + resolution: "require-directory@npm:2.1.1" + checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99 + languageName: node + linkType: hard + +"require-from-string@npm:^2.0.2": + version: 2.0.2 + resolution: "require-from-string@npm:2.0.2" + checksum: 10c0/aaa267e0c5b022fc5fd4eef49d8285086b15f2a1c54b28240fdf03599cbd9c26049fee3eab894f2e1f6ca65e513b030a7c264201e3f005601e80c49fb2937ce2 + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -3417,6 +4378,13 @@ __metadata: languageName: node linkType: hard +"resolve-from@npm:^5.0.0": + version: 5.0.0 + resolution: "resolve-from@npm:5.0.0" + checksum: 10c0/b21cb7f1fb746de8107b9febab60095187781137fd803e6a59a76d421444b1531b641bba5857f5dc011974d8a5c635d61cec49e6bd3b7fc20e01f0fafc4efbf2 + languageName: node + linkType: hard + "resolve-pkg-maps@npm:^1.0.0": version: 1.0.0 resolution: "resolve-pkg-maps@npm:1.0.0" @@ -3424,6 +4392,42 @@ __metadata: languageName: node linkType: hard +"resolve@npm:^1.22.2": + version: 1.22.10 + resolution: "resolve@npm:1.22.10" + dependencies: + is-core-module: "npm:^2.16.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/8967e1f4e2cc40f79b7e080b4582b9a8c5ee36ffb46041dccb20e6461161adf69f843b43067b4a375de926a2cd669157e29a29578191def399dd5ef89a1b5203 + languageName: node + linkType: hard + +"resolve@patch:resolve@npm%3A^1.22.2#optional!builtin": + version: 1.22.10 + resolution: "resolve@patch:resolve@npm%3A1.22.10#optional!builtin::version=1.22.10&hash=c3c19d" + dependencies: + is-core-module: "npm:^2.16.0" + path-parse: "npm:^1.0.7" + supports-preserve-symlinks-flag: "npm:^1.0.0" + bin: + resolve: bin/resolve + checksum: 10c0/52a4e505bbfc7925ac8f4cd91fd8c4e096b6a89728b9f46861d3b405ac9a1ccf4dcbf8befb4e89a2e11370dacd0160918163885cbc669369590f2f31f4c58939 + languageName: node + linkType: hard + +"restore-cursor@npm:^5.0.0": + version: 5.1.0 + resolution: "restore-cursor@npm:5.1.0" + dependencies: + onetime: "npm:^7.0.0" + signal-exit: "npm:^4.1.0" + checksum: 10c0/c2ba89131eea791d1b25205bdfdc86699767e2b88dee2a590b1a6caa51737deac8bad0260a5ded2f7c074b7db2f3a626bcf1fcf3cdf35974cbeea5e2e6764f60 + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -3438,6 +4442,13 @@ __metadata: languageName: node linkType: hard +"rfdc@npm:^1.4.1": + version: 1.4.1 + resolution: "rfdc@npm:1.4.1" + checksum: 10c0/4614e4292356cafade0b6031527eea9bc90f2372a22c012313be1dcc69a3b90c7338158b414539be863fa95bfcb2ddcd0587be696841af4e6679d85e62c060c7 + languageName: node + linkType: hard + "rimraf@npm:^3.0.2": version: 3.0.2 resolution: "rimraf@npm:3.0.2" @@ -3474,7 +4485,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.6.2": +"semver@npm:^7.6.3": version: 7.7.1 resolution: "semver@npm:7.7.1" bin: @@ -3499,20 +4510,33 @@ __metadata: languageName: node linkType: hard -"shell-quote@npm:^1.8.1": - version: 1.8.2 - resolution: "shell-quote@npm:1.8.2" - checksum: 10c0/85fdd44f2ad76e723d34eb72c753f04d847ab64e9f1f10677e3f518d0e5b0752a176fd805297b30bb8c3a1556ebe6e77d2288dbd7b7b0110c7e941e9e9c20ce1 - languageName: node - linkType: hard - -"signal-exit@npm:^4.0.1": +"signal-exit@npm:^4.0.1, signal-exit@npm:^4.1.0": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" checksum: 10c0/41602dce540e46d599edba9d9860193398d135f7ff72cab629db5171516cfae628d21e7bfccde1bbfdf11c48726bc2a6d1a8fb8701125852fbfda7cf19c6aa83 languageName: node linkType: hard +"slice-ansi@npm:^5.0.0": + version: 5.0.0 + resolution: "slice-ansi@npm:5.0.0" + dependencies: + ansi-styles: "npm:^6.0.0" + is-fullwidth-code-point: "npm:^4.0.0" + checksum: 10c0/2d4d40b2a9d5cf4e8caae3f698fe24ae31a4d778701724f578e984dcb485ec8c49f0c04dab59c401821e80fcdfe89cace9c66693b0244e40ec485d72e543914f + languageName: node + linkType: hard + +"slice-ansi@npm:^7.1.0": + version: 7.1.0 + resolution: "slice-ansi@npm:7.1.0" + dependencies: + ansi-styles: "npm:^6.2.1" + is-fullwidth-code-point: "npm:^5.0.0" + checksum: 10c0/631c971d4abf56cf880f034d43fcc44ff883624867bf11ecbd538c47343911d734a4656d7bc02362b40b89d765652a7f935595441e519b59e2ad3f4d5d6fe7ca + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -3558,7 +4582,7 @@ __metadata: languageName: node linkType: hard -"split2@npm:^4.1.0": +"split2@npm:^4.0.0, split2@npm:^4.1.0": version: 4.2.0 resolution: "split2@npm:4.2.0" checksum: 10c0/b292beb8ce9215f8c642bb68be6249c5a4c7f332fc8ecadae7be5cbdf1ea95addc95f0459ef2e7ad9d45fd1064698a097e4eb211c83e772b49bc0ee423e91534 @@ -3588,7 +4612,14 @@ __metadata: languageName: node linkType: hard -"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0": +"string-argv@npm:^0.3.2": + version: 0.3.2 + resolution: "string-argv@npm:0.3.2" + checksum: 10c0/75c02a83759ad1722e040b86823909d9a2fc75d15dd71ec4b537c3560746e33b5f5a07f7332d1e3f88319909f82190843aa2f0a0d8c8d591ec08e93d5b8dec82 + languageName: node + linkType: hard + +"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3": version: 4.2.3 resolution: "string-width@npm:4.2.3" dependencies: @@ -3610,6 +4641,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^7.0.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: "npm:^10.3.0" + get-east-asian-width: "npm:^1.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/eb0430dd43f3199c7a46dcbf7a0b34539c76fe3aa62763d0b0655acdcbdf360b3f66f3d58ca25ba0205f42ea3491fa00f09426d3b7d3040e506878fc7664c9b9 + languageName: node + linkType: hard + "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -3619,7 +4661,7 @@ __metadata: languageName: node linkType: hard -"strip-ansi@npm:^7.0.1": +"strip-ansi@npm:^7.0.1, strip-ansi@npm:^7.1.0": version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" dependencies: @@ -3628,6 +4670,13 @@ __metadata: languageName: node linkType: hard +"strip-final-newline@npm:^3.0.0": + version: 3.0.0 + resolution: "strip-final-newline@npm:3.0.0" + checksum: 10c0/a771a17901427bac6293fd416db7577e2bc1c34a19d38351e9d5478c3c415f523f391003b42ed475f27e33a78233035df183525395f731d3bfb8cdcbd4da08ce + languageName: node + linkType: hard + "strip-json-comments@npm:^3.1.1": version: 3.1.1 resolution: "strip-json-comments@npm:3.1.1" @@ -3644,6 +4693,13 @@ __metadata: languageName: node linkType: hard +"supports-preserve-symlinks-flag@npm:^1.0.0": + version: 1.0.0 + resolution: "supports-preserve-symlinks-flag@npm:1.0.0" + checksum: 10c0/6c4032340701a9950865f7ae8ef38578d8d7053f5e10518076e6554a9381fa91bd9c6850193695c141f32b21f979c985db07265a758867bac95de05f7d8aeb39 + languageName: node + linkType: hard + "tar@npm:^6.1.11, tar@npm:^6.2.1": version: 6.2.1 resolution: "tar@npm:6.2.1" @@ -3658,6 +4714,13 @@ __metadata: languageName: node linkType: hard +"text-extensions@npm:^2.0.0": + version: 2.4.0 + resolution: "text-extensions@npm:2.4.0" + checksum: 10c0/6790e7ee72ad4d54f2e96c50a13e158bb57ce840dddc770e80960ed1550115c57bdc2cee45d5354d7b4f269636f5ca06aab4d6e0281556c841389aa837b23fcb + languageName: node + linkType: hard + "text-table@npm:^0.2.0": version: 0.2.0 resolution: "text-table@npm:0.2.0" @@ -3665,6 +4728,20 @@ __metadata: languageName: node linkType: hard +"through@npm:>=2.2.7 <3": + version: 2.3.8 + resolution: "through@npm:2.3.8" + checksum: 10c0/4b09f3774099de0d4df26d95c5821a62faee32c7e96fb1f4ebd54a2d7c11c57fe88b0a0d49cf375de5fee5ae6bf4eb56dbbf29d07366864e2ee805349970d3cc + languageName: node + linkType: hard + +"tinyexec@npm:^0.3.0": + version: 0.3.2 + resolution: "tinyexec@npm:0.3.2" + checksum: 10c0/3efbf791a911be0bf0821eab37a3445c2ba07acc1522b1fa84ae1e55f10425076f1290f680286345ed919549ad67527d07281f1c19d584df3b74326909eb1f90 + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -3728,6 +4805,23 @@ __metadata: languageName: node linkType: hard +"ts-patch@npm:^3.3.0": + version: 3.3.0 + resolution: "ts-patch@npm:3.3.0" + dependencies: + chalk: "npm:^4.1.2" + global-prefix: "npm:^4.0.0" + minimist: "npm:^1.2.8" + resolve: "npm:^1.22.2" + semver: "npm:^7.6.3" + strip-ansi: "npm:^6.0.1" + bin: + ts-patch: bin/ts-patch.js + tspc: bin/tspc.js + checksum: 10c0/41abfa08ea70755f44f39c32b8906479cddf66f163ea37bdd8b543dcda548ec6cc3d7b6f53371161fbfaa9ff48e4fbb0d5839f46f425f7058f7710253e607c20 + languageName: node + linkType: hard + "tslib@npm:^2.6.2, tslib@npm:^2.6.3": version: 2.7.0 resolution: "tslib@npm:2.7.0" @@ -3735,9 +4829,9 @@ __metadata: languageName: node linkType: hard -"tsx@npm:^4.19.3": - version: 4.19.3 - resolution: "tsx@npm:4.19.3" +"tsx@npm:^4.19.4": + version: 4.19.4 + resolution: "tsx@npm:4.19.4" dependencies: esbuild: "npm:~0.25.0" fsevents: "npm:~2.3.3" @@ -3747,7 +4841,7 @@ __metadata: optional: true bin: tsx: dist/cli.mjs - checksum: 10c0/cacfb4cf1392ae10e8e4fe032ad26ccb07cd8a3b32e5a0da270d9c48d06ee74f743e4a84686cbc9d89b48032d59bbc56cd911e076f53cebe61dc24fa525ff790 + checksum: 10c0/f7b8d44362343fbde1f2ecc9832d243a450e1168dd09702a545ebe5f699aa6912e45b431a54b885466db414cceda48e5067b36d182027c43b2c02a4f99d8721e languageName: node linkType: hard @@ -3767,23 +4861,34 @@ __metadata: languageName: node linkType: hard -"typescript@npm:^5.8.2": - version: 5.8.2 - resolution: "typescript@npm:5.8.2" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10c0/5c4f6fbf1c6389b6928fe7b8fcd5dc73bb2d58cd4e3883f1d774ed5bd83b151cbac6b7ecf11723de56d4676daeba8713894b1e9af56174f2f9780ae7848ec3c6 +"typescript-transform-paths@npm:^3.5.5": + version: 3.5.5 + resolution: "typescript-transform-paths@npm:3.5.5" + dependencies: + minimatch: "npm:^9.0.5" + peerDependencies: + typescript: ">=3.6.5" + checksum: 10c0/253aa063b43588753ac651c12b22e1e2ce32273a0b5a59be038de7aba70b95e3363461bc2cc6ad5244525890c90f3ee350fe70fa0680846614eadf92738a87ed languageName: node linkType: hard -"typescript@patch:typescript@npm%3A^5.8.2#optional!builtin": - version: 5.8.2 - resolution: "typescript@patch:typescript@npm%3A5.8.2#optional!builtin::version=5.8.2&hash=5786d5" +"typescript@npm:^5.8.3": + version: 5.8.3 + resolution: "typescript@npm:5.8.3" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10c0/5448a08e595cc558ab321e49d4cac64fb43d1fa106584f6ff9a8d8e592111b373a995a1d5c7f3046211c8a37201eb6d0f1566f15cdb7a62a5e3be01d087848e2 + checksum: 10c0/5f8bb01196e542e64d44db3d16ee0e4063ce4f3e3966df6005f2588e86d91c03e1fb131c2581baf0fb65ee79669eea6e161cd448178986587e9f6844446dbb48 + languageName: node + linkType: hard + +"typescript@patch:typescript@npm%3A^5.8.3#optional!builtin": + version: 5.8.3 + resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10c0/39117e346ff8ebd87ae1510b3a77d5d92dae5a89bde588c747d25da5c146603a99c8ee588c7ef80faaf123d89ed46f6dbd918d534d641083177d5fac38b8a1cb languageName: node linkType: hard @@ -3808,6 +4913,13 @@ __metadata: languageName: node linkType: hard +"unicorn-magic@npm:^0.1.0": + version: 0.1.0 + resolution: "unicorn-magic@npm:0.1.0" + checksum: 10c0/e4ed0de05b0a05e735c7d8a2930881e5efcfc3ec897204d5d33e7e6247f4c31eac92e383a15d9a6bccb7319b4271ee4bea946e211bf14951fec6ff2cbbb66a92 + languageName: node + linkType: hard + "unique-filename@npm:^3.0.0": version: 3.0.0 resolution: "unique-filename@npm:3.0.0" @@ -3878,7 +4990,7 @@ __metadata: languageName: node linkType: hard -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0": version: 7.0.0 resolution: "wrap-ansi@npm:7.0.0" dependencies: @@ -3900,6 +5012,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^9.0.0": + version: 9.0.0 + resolution: "wrap-ansi@npm:9.0.0" + dependencies: + ansi-styles: "npm:^6.2.1" + string-width: "npm:^7.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 10c0/a139b818da9573677548dd463bd626a5a5286271211eb6e4e82f34a4f643191d74e6d4a9bb0a3c26ec90e6f904f679e0569674ac099ea12378a8b98e20706066 + languageName: node + linkType: hard + "wrappy@npm:1": version: 1.0.2 resolution: "wrappy@npm:1.0.2" @@ -3929,6 +5052,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^5.0.5": + version: 5.0.8 + resolution: "y18n@npm:5.0.8" + checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249 + languageName: node + linkType: hard + "yallist@npm:^4.0.0": version: 4.0.0 resolution: "yallist@npm:4.0.0" @@ -3936,6 +5066,37 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.7.0": + version: 2.7.0 + resolution: "yaml@npm:2.7.0" + bin: + yaml: bin.mjs + checksum: 10c0/886a7d2abbd70704b79f1d2d05fe9fb0aa63aefb86e1cb9991837dced65193d300f5554747a872b4b10ae9a12bc5d5327e4d04205f70336e863e35e89d8f4ea9 + languageName: node + linkType: hard + +"yargs-parser@npm:^21.1.1": + version: 21.1.1 + resolution: "yargs-parser@npm:21.1.1" + checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2 + languageName: node + linkType: hard + +"yargs@npm:^17.0.0": + version: 17.7.2 + resolution: "yargs@npm:17.7.2" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05 + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1" @@ -3949,3 +5110,10 @@ __metadata: checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f languageName: node linkType: hard + +"yocto-queue@npm:^1.0.0": + version: 1.2.0 + resolution: "yocto-queue@npm:1.2.0" + checksum: 10c0/9fb3adeba76b69cc7c916831c092bb69ac1aa685c692ae6eb819a9599cbe0c4ecfd5269c145691a15b86d0a25b27d854d6116bbc0851a3373c0a86edb96f1602 + languageName: node + linkType: hard