diff --git a/.dockerignore b/.dockerignore index 1308c228b..bfb4e2c5d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,6 +1,13 @@ # Nextjs Files .next +# Runtime data & secrets (host-mounted or gitignored) +data +.env +db.sqlite* +application.yml +*.tsbuildinfo + # Docker Files Dockerfile docker-compose.yml @@ -11,7 +18,7 @@ docker-compose.yaml .git .github .gitignore -LICENSE +LICENSE* README.md # Node Modules and lint settings diff --git a/.env.example b/.env.example index cd6d5eadf..dc80a2fb7 100644 --- a/.env.example +++ b/.env.example @@ -1,34 +1,52 @@ -# DB URL -DATABASE_URL="postgresql://john:doe@localhost:5432/master-bot?schema=public" - -# Bot Token -DISCORD_TOKEN="" - -NEXTAUTH_SECRET="somesupersecrettwelvelengthword" -NEXTAUTH_URL= -NEXTAUTH_URL_INTERNAL=http://localhost:3000 -NEXT_PUBLIC_INVITE_URL="https://discord.com/api/oauth2/authorize?client_id=yourclientid&permissions=8&scope=bot" - -# Next Auth Discord Provider -DISCORD_CLIENT_ID="" -DISCORD_CLIENT_SECRET="" - -# Lavalink -LAVA_HOST="0.0.0.0" -LAVA_PASS="youshallnotpass" -LAVA_PORT=2333 -LAVA_SECURE=false - -# Spotify -SPOTIFY_CLIENT_ID="" -SPOTIFY_CLIENT_SECRET="" - -# Twitch -TWITCH_CLIENT_ID="" -TWITCH_CLIENT_SECRET="" - -# Other APIs -TENOR_API="" -NEWS_API="" -GENIUS_API="" -RAWG_API="" +# SQLite Database (Zero configuration, local embedded database) +# Stored at /data/bot.sqlite by default; auto-created on first start. +# Override the file location with DISCORD_DB_PATH if desired. +# DISCORD_DB_PATH="/absolute/path/to/bot.sqlite" + +# Unified Runtime Port +# The bot, embedded web dashboard and OAuth2 callback server share ONE port. +# Access the dashboard at http://localhost:3000/dashboard +PORT=3000 + +# Discord Bot Credentials +DISCORD_TOKEN="" # Discord bot token from the Developer Portal +DISCORD_CLIENT_ID="" # Discord application client ID +DISCORD_CLIENT_SECRET="" # Discord application client secret (used by the dashboard OAuth2 login) +DISCORD_OWNER_ID="" # Discord user ID treated as the bot owner (for owner-only commands) + +# Dashboard / OAuth2 (all optional โ€” auto-resolved from PORT) +# NEXTAUTH_URL="https://your-domain.com" # Public base URL when deploying (defaults to http://localhost:) +# NEXTAUTH_SECRET="somesupersecretvalue" # HMAC secret signing dashboard session tokens (auto-generated fallback) +# DISCORD_CALLBACK_URL="https://your-domain.com/api/auth/callback/discord" # Full callback URL, if you'd rather not set NEXTAUTH_URL + +# Lavalink v4 Audio Engine (Music Streaming) +LAVA_ENABLED=true # Master toggle for Lavalink audio engine and music commands +LAVA_HOST="127.0.0.1" # Lavalink host (use 'lavalink' when running via docker-compose) +LAVA_PASS="youshallnotpass" # Lavalink password (must match application.yml) +LAVA_PORT=2333 # Lavalink WebSocket / HTTP port +LAVA_SECURE=false # Enable SSL/WSS encryption (true / false) +LAVA_EXTERNAL=false # Set to true to connect to an external Lavalink instance + +# YouTube & Remote Cipher +YOUTUBE_REFRESH_TOKEN="" # YouTube OAuth 2.0 refresh token (auto-saved to .youtube-oauth.json) +YOUTUBE_API_KEY="" # Optional YouTube Data API v3 key +YOUTUBE_CIPHER_URL="https://cipher.kikkia.dev/" # Remote cipher endpoint for YouTube signature deciphering +YOUTUBE_CIPHER_PASSWORD="" # Optional password for self-hosted yt-cipher (leave empty for default public endpoint) + +# Spotify Metadata +SPOTIFY_CLIENT_ID="" # Spotify Developer App Client ID +SPOTIFY_CLIENT_SECRET="" # Spotify Developer App Client Secret +# SOUNDCLOUD_CLIENT_ID="" # Optional SoundCloud client credentials (free sources need no keys) + +# Twitch Stream Alerts & IGDB +TWITCH_ENABLED=true # Toggle for Twitch stream monitoring and notifications +TWITCH_CLIENT_ID="" # Twitch Developer App Client ID (used for Twitch alerts & IGDB search) +TWITCH_CLIENT_SECRET="" # Twitch Developer App Client Secret +IGDB_ENABLED=true # Toggle for IGDB game database lookups + +# Media & Search APIs +GIFS_ENABLED=true # Toggle for animated GIF and reaction commands +KLIPY_API="" # API key for anime reactions and interactive GIFs +NEWS_ENABLED=true # Toggle for news headline commands +NEWS_API="" # NewsAPI key for /world-news global headline searches +GENIUS_API="" # Genius API client token for /lyrics song lyrics lookup \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 18daf29ff..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: Bug report -about: Create a report -title: '' -labels: 'bug' -assignees: '' ---- - -### IMPORTANT : _DO NOT SKIP THIS STEPS AND DO NOT DELETE THEM. WE CAN NOT HELP YOU IF YOU DO NOT PROVIDE INFORMATION AND STEPS TO REPRODUCE_ - -Do not open an issue if you simply "copied" code over to your bot/another bot. This is absolutely not recommended and will cause bugs. Also do not open an issue if you modified code and added features and now it's not working right. This is because I can't figure it out and don't have the time to read your code and find out what you did wrong. - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -1. Use 'x' command -2. provide 'y' argument - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - -- OS: [e.g. Windows, Ubuntu...]: -- Node.js Version(Should be v16 at least): -- Is python 2.7 installed?: -- How are you hosting the bot(Locally, on a vps, heroku, glitch...): - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..fb4e9fc9f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,67 @@ +name: Bug Report +description: Create a report to help us improve Master-Bot +title: '[Bug]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + ### Instructions + Please provide detailed information to help reproduce and fix the bug. + + - type: textarea + id: description + attributes: + label: Describe the Bug + description: A clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Use command `/...` + 2. Pass argument `...` + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - Windows + - Linux (Ubuntu/Debian) + - macOS + - Docker + - Other + validations: + required: true + + - type: input + id: node-version + attributes: + label: Node.js Version + placeholder: "e.g., v20.11.0" + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional Context / Logs + description: Add any error logs, stack traces, or screenshots here. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/command_issue.yml b/.github/ISSUE_TEMPLATE/command_issue.yml new file mode 100644 index 000000000..2593721c3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/command_issue.yml @@ -0,0 +1,55 @@ +name: Command Issue +description: Report a problem with a specific slash command +title: '[Command]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + ### Instructions + Describe the command that is not working as expected and what should happen instead. + + - type: input + id: command + attributes: + label: Command + description: The slash command that has an issue. + placeholder: "/play" + validations: + required: true + + - type: textarea + id: description + attributes: + label: Describe the Issue + description: What went wrong? + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Run `/...` + 2. Pass arguments `...` + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen instead? + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Error Output / Logs + description: Paste any Discord error message or bot log output. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3ba13e0ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/dashboard_issue.yml b/.github/ISSUE_TEMPLATE/dashboard_issue.yml new file mode 100644 index 000000000..a1a7c5a80 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/dashboard_issue.yml @@ -0,0 +1,62 @@ +name: Dashboard / Web Issue +description: Report a problem with the web dashboard, authentication, or API +title: '[Dashboard]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + ### Instructions + Describe the web dashboard, authentication, or API issue you encountered. + + - type: input + id: url + attributes: + label: Page / Route + description: The dashboard page or API route affected. + placeholder: "e.g., /dashboard/[server_id]/welcome-message" + validations: + required: false + + - type: textarea + id: description + attributes: + label: Describe the Issue + description: What happened? Include any error messages. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Navigate to ... + 2. Click ... + 3. See error + validations: + required: true + + - type: dropdown + id: area + attributes: + label: Area + options: + - Authentication / Sign-In + - Server Settings + - Welcome Message Editor + - Ticket Panel + - Log Viewer + - Commands Panel + - Other + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Browser / Server Console + description: Paste any console errors or dashboard log output. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index c38d541ac..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -name: Feature request -about: Suggest/request a new bot feature -title: '' -labels: 'enhancement' -assignees: '' ---- - -**Explain your suggestion** diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..85df1d06a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,21 @@ +name: Feature Request +description: Suggest an idea or new feature for Master-Bot +title: '[Feature]: ' +labels: ['enhancement'] +body: + - type: textarea + id: feature-description + attributes: + label: Feature Description + description: Explain your suggestion or proposed feature in detail. + placeholder: Describe what feature you would like to see and why. + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: Use Case / Problem Statement + description: Is your feature request related to a problem or specific workflow? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/music_audio_bug.yml b/.github/ISSUE_TEMPLATE/music_audio_bug.yml new file mode 100644 index 000000000..37e179d19 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/music_audio_bug.yml @@ -0,0 +1,69 @@ +name: Music / Audio Bug +description: Report a music or audio playback issue (Lavalink, YouTube, Spotify, SoundCloud, Twitch, etc.) +title: '[Music]: ' +labels: ['bug'] +body: + - type: markdown + attributes: + value: | + ### Instructions + Please provide detailed information so we can diagnose the audio playback issue. + + - type: textarea + id: description + attributes: + label: Describe the Issue + description: What happened during playback? Include the command used and the track or source involved. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Run `/play ...` + 2. Join a voice channel + 3. Observe the failure + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen instead? + validations: + required: true + + - type: dropdown + id: source + attributes: + label: Track Source + options: + - YouTube + - Spotify + - SoundCloud + - Twitch + - Direct URL / File + - Other / Unknown + validations: + required: false + + - type: input + id: lavalink-version + attributes: + label: Lavalink Version + description: Version of Lavalink in use (see `application.yml`). + placeholder: "e.g., v4.x" + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Lavalink / Bot Logs + description: Paste any relevant log output (e.g. `logs/lavalink.log`, `logs/bot.log`), including error codes and stack traces. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 000000000..c644c9564 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,20 @@ +name: Question +description: Ask a question about setting up or using Master-Bot +title: '[Question]: ' +labels: ['question'] +body: + - type: textarea + id: question + attributes: + label: Your Question + description: What would you like to know? + validations: + required: true + + - type: textarea + id: context + attributes: + label: Relevant Context + description: Anything that helps us answer (OS, setup method, error, etc.). + validations: + required: false diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a99ff8cdc..da7f336ab 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,11 +1,44 @@ -on: [pull_request] +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] jobs: - prettier: + build: runs-on: ubuntu-latest + steps: - name: Checkout Repository - - uses: actions/checkout@v3 + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 8.6.7 + + - name: Setup Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install Dependencies + run: pnpm install --frozen-lockfile - - name: Build App + - name: Code Formatting Check run: npx prettier . --check + + - name: Lint + run: pnpm lint + + - name: Test (Vitest) + run: pnpm test + + - name: Type Check + run: pnpm type-check + + - name: Build + run: pnpm build diff --git a/.gitignore b/.gitignore index 8630e8a83..af08de829 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,20 @@ *.pem .env .env*.local +.youtube-oauth.json +.youtube-oauth.json.tmp +.youtube-oauth*.json +*.youtube-oauth.json + +# Local tracking plan (never commit) +PLAN.md +AGENTS.md +agents/ +.agents/ +.gemini/ +.copilot/ +.opencode/ +scratch/ # Turbo .turbo @@ -27,6 +41,7 @@ out # Lavalink Lavalink.jar +plugins/ application.yml application.yaml diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..06bde4ab5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,216 @@ +# Contributing to Master-Bot ๐Ÿค + +Thank you for your interest in contributing to **Master-Bot**! Master-Bot is an open-source Discord music and utility bot with a full-featured web dashboard. We welcome contributions of all kindsโ€”bug fixes, new features, documentation improvements, UI polish, and performance optimizations. + +Please take a few moments to review this guide before opening an issue or submitting a pull request. + +--- + +## ๐Ÿ“‘ Table of Contents + +1. [Code of Conduct](#-code-of-conduct) +2. [Project Architecture](#-project-architecture) +3. [Prerequisites & Development Setup](#-prerequisites--development-setup) +4. [Development Workflow](#-development-workflow) +5. [Coding Standards & Conventions](#-coding-standards--conventions) +6. [Commit & Pull Request Guidelines](#-commit--pull-request-guidelines) +7. [Reporting Bugs & Suggesting Features](#-reporting-bugs--suggesting-features) +8. [Community & Getting Help](#-community--getting-help) + +--- + +## ๐Ÿ“œ Code of Conduct + +We are committed to providing a welcoming, inclusive, and harassment-free experience for everyone. Please be respectful, constructive, and considerate in all interactionsโ€”whether in issues, pull requests, or community discussions. + +--- + +## ๐Ÿ—๏ธ Project Architecture + +Master-Bot is organized as a [Turborepo](https://turbo.build/) workspace managed with [pnpm](https://pnpm.io/workspaces): + +| Package / App | Location | Technology Stack | Responsibility | +| :-------------------------- | :--------------- | :--------------------------------------------------------- | :------------------------------------------------------------------------ | +| **`@master-bot/bot`** | `apps/bot` | Sapphire Framework, `discord.js` v14, `lavalink-client` v2 | Discord client, music playback, slash commands, moderation, ticket system | +| **`@master-bot/dashboard`** | `apps/dashboard` | Plain Node.js HTTP server (embedded in the bot process) | Web dashboard served from `apps/bot` โ€” settings, stats, OAuth2 login | +| **`@master-bot/db`** | `packages/db` | `node:sqlite` (Node 22+, zero dependencies) | Hand-rolled SQLite data layer, typed CRUD, `data/bot.sqlite` (auto-created)| +| **`Launcher Scripts`** | `scripts/` | Node.js ESM (`.mjs`), child processes | Cross-platform dev & prod orchestration, port cleanup, log routing | + +--- + +## ๐Ÿ› ๏ธ Prerequisites & Development Setup + +### System Requirements + +- **Node.js**: `>=22.0.0` (required for `node:sqlite` and the modern toolchain) +- **pnpm**: `>=8.0.0` (`npm install -g pnpm`) +- **Java**: Java 17 or higher (Java 21 LTS recommended for Lavalink v4) +- **SQLite**: None! The database is embedded, auto-created at `/data/bot.sqlite` + +### Setup Steps + +1. **Fork and Clone the Repository**: + + ```bash + git clone https://github.com//Master-Bot.git + cd Master-Bot + ``` + +2. **Install Dependencies**: + + ```bash + pnpm install + ``` + +3. **Configure Environment Variables**: + Copy `.env.example` to `.env`: + + ```bash + cp .env.example .env + ``` + + Fill in your development credentials: + - `DISCORD_TOKEN`: Bot token from the [Discord Developer Portal](https://discord.com/developers/applications) + - `DISCORD_CLIENT_ID` & `DISCORD_CLIENT_SECRET`: Application OAuth2 credentials (dashboard login) + - `PORT`: Single unified HTTP port (default: `3000`) shared by bot, dashboard and OAuth2 callbacks + - `LAVA_ENABLED`: Set to `true` if you wish to run and test audio playback. + +4. **Lavalink Configuration (Optional for non-music development)**: + If developing audio features, copy `application.yml.example` to `application.yml` and ensure `Lavalink.jar` (v4) is present in the workspace root. + +5. **Start Development Stack**: + ```bash + pnpm dev + ``` + The unified launcher starts the single-process bot (embedding the dashboard), auto-creates the SQLite database on first start, optionally spawns Lavalink, clears lingering ports, and routes logs to `logs/`. + +--- + +## ๐Ÿ”„ Development Workflow + +### Branching Strategy + +- Create a descriptive feature or bugfix branch from `main`: + ```bash + git checkout -b feat/my-new-feature + # or + git checkout -b fix/issue-description + ``` + +### Validation & Verification Commands + +Before committing or opening a pull request, always verify that your changes compile and pass type checks with **0 errors**: + +```bash +# Type-check all packages +pnpm --filter @master-bot/db type-check +pnpm --filter @master-bot/dashboard type-check + +# Compile the Discord bot application +pnpm --filter @master-bot/bot build + +# Build the web dashboard +pnpm --filter @master-bot/dashboard build +``` + +--- + +## ๐Ÿ“ Coding Standards & Conventions + +### General Principles + +- **Root-Cause Fixes**: Always trace bugs to their fundamental architectural cause rather than implementing temporary workarounds. +- **Cross-Platform Parity**: Every feature, script, and command must function reliably across **Windows, macOS, and Linux**. +- **Non-Destructive Modifications**: Avoid deleting existing repository files unless they are verified to be unused dead code with zero imports. + +### Bot & Discord.js Standards (`apps/bot`) + +- **Sapphire Events**: Always use the official `Events` enum from `@sapphire/framework` (e.g. `Events.ChatInputCommandError`, `Events.ClientReady`). Never use magic strings. +- **Lightweight Preconditions**: Avoid slow, uncached database or network queries in preconditions to ensure Discord interaction tokens do not exceed the strict 3-second response deadline. +- **Interaction Reply Safety**: Use `interaction.deferReply()` for long-running commands, and ensure deferred interactions are updated via `interaction.editReply()`. +- **Structured Logging**: Route errors through `Logger.error()` (`apps/bot/src/lib/logger.ts`) with contextual metadata. + +### Dashboard & API Standards (`apps/dashboard`) + +- **Single-Process Embedding**: The dashboard runs as a plain Node.js `http` server embedded in the bot process (`apps/bot/src/server.ts`); it shares the unified `PORT` with the bot and OAuth2 callback route. +- **Typed Handlers**: Route handlers live in `apps/dashboard/src/router.ts` and call the bot through the `dataService` facade (`apps/bot/src/dataService.ts`) โ€” no RPC framework, end-to-end TypeScript by import. +- **OAuth2 Sessions**: Auth/session logic in `apps/dashboard/src/auth/` (config + handlers) mirrors the NextAuth-compatible cookie format. + +### Security & Git Hygiene + +- **Zero Disk Secret Mutation**: Never write runtime credentials into `.env` at runtime. +- **Strict Gitignore**: Runtime files (`.env`, `.youtube-oauth.json`, `Lavalink.jar`, `logs/`) must **never** be tracked or committed to Git. + +--- + +## ๐Ÿ“ฆ Commit & Pull Request Guidelines + +### Conventional Commits + +All commit messages must strictly follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: + +```text +(): +``` + +#### Allowed Types + +- `feat`: A new feature or capability +- `fix`: A bug fix +- `docs`: Documentation updates or corrections +- `refactor`: Code restructure without changing behavior +- `perf`: A code change that improves performance +- `test`: Adding or updating tests +- `chore`: Maintenance tasks, dependency updates, tooling +- `build`: Changes affecting build system or external dependencies +- `ci`: Continuous integration configuration changes + +#### Common Scopes + +- `bot`, `dashboard`, `db`, `launcher`, `music`, `moderation`, `tickets`, `settings`, `deps`, `docs` + +#### Examples + +- `feat(music): add live ascii progress bar and auto-updating player embed` +- `fix(bot): replace followUp with editReply on deferred interactions` +- `docs(readme): update commands table and contributor references` + +--- + +### Opening a Pull Request + +1. **Title**: Use a clear, concise Conventional Commit format (e.g., `feat(tickets): add dynamic greeting placeholders`). +2. **Description**: + - Explain the motivation and context behind the change. + - List key modifications and affected components. + - Include verification details (type-check output, screenshots for UI changes). +3. **Keep PRs Focused**: Avoid bundling unrelated refactors or formatting changes with feature implementations. + +--- + +## ๐Ÿ› Reporting Bugs & Suggesting Features + +### Reporting a Bug + +- Check [existing GitHub Issues](https://github.com/galnir/Master-Bot/issues) to ensure the issue hasn't already been reported. +- Provide a clear, reproducible description including: + - Operating system and Node.js / Java versions. + - Relevant log snippets from `logs/bot.log` or `logs/lavalink.log`. + - Exact steps to reproduce the behavior. + +### Suggesting a Feature + +- Open a Feature Request issue describing: + - The problem or use case your feature solves. + - Proposed slash command syntax or dashboard UI workflow. + - Any architectural considerations. + +--- + +## ๐Ÿ’ฌ Community & Getting Help + +- **Repository**: [galnir/Master-Bot](https://github.com/galnir/Master-Bot) +- **Documentation Wiki**: [Master-Bot Wiki](wiki/Home.md) +- **Discussions & Issues**: [GitHub Issues](https://github.com/galnir/Master-Bot/issues) + +Thank you for helping make Master-Bot better for everyone! ๐Ÿš€ diff --git a/Dockerfile b/Dockerfile index 30e1811e6..afc8404dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,22 @@ -FROM --platform=linux/amd64 node:18-slim +FROM --platform=linux/amd64 node:22-slim ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" -ENV NEXT_TELEMETRY_DISABLED 1 WORKDIR "/Master-Bot" -# Ports for the Dashboard +# Single unified HTTP port shared by the bot, embedded dashboard and OAuth2 +# callback server (HELIX single-process model) EXPOSE 3000 ENV PORT 3000 -# Install prerequisites and register fonts -RUN apt-get update && apt-get upgrade -y -q && \ - apt-get install -y -q openssl && \ - apt-get install -y -q --no-install-recommends libfontconfig1 && \ - npm install -g pnpm +# Install pnpm matching the repository's packageManager field +RUN npm install -g pnpm@8.6.7 # Copy files to Container (Excluding whats in .dockerignore) COPY ./ ./ -RUN pnpm install --ignore-scripts && pnpm -F * build +RUN pnpm install --ignore-scripts && pnpm build && pnpm --filter @master-bot/bot copy-scripts -# If you are running Master-Bot in a Standalone Container and need to connect to a service on localhost uncomment the following ENV for each service running on the containers host -# ENV POSTGRES_HOST="host.docker.internal" -# ENV REDIS_HOST="host.docker.internal" +# If you are running Master-Bot in a Standalone Container and need to connect +# to Lavalink on the container's host, uncomment the following ENV: # ENV LAVA_HOST="host.docker.internal" -# Uncomment the following for Standalone Master-Bot Docker Container Build -# RUN pnpm db:push -# CMD ["pnpm", "-r", "start"] \ No newline at end of file +CMD ["pnpm", "start"] \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 435503eb6..000000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 Julius Marminge - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..bdba7ad55 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,30 @@ +# ๐Ÿ“„ MIT License + +**Master-Bot** is open-source software licensed under the [MIT License](https://opensource.org/licenses/MIT). + +--- + +### Copyright (c) 2023โ€“2026 Master-Bot Contributors & Julius Marminge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the **"Software"**), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +--- + +### Disclaimer + +> [!IMPORTANT] +> **THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE.** diff --git a/README.md b/README.md index ef92bb096..8c2542bfd 100644 --- a/README.md +++ b/README.md @@ -1,143 +1,118 @@ -# A Discord Music Bot written in TypeScript using Sapphire, discord.js, Next.js and React +# A Discord Music Bot written in TypeScript using Sapphire, discord.js and an embedded web dashboard [![image](https://img.shields.io/badge/language-typescript-blue)](https://www.typescriptlang.org) -[![image](https://img.shields.io/badge/node-%3E%3D%2016.0.0-blue)](https://nodejs.org/) +[![image](https://img.shields.io/badge/node-%3E%3D%2022.0.0-blue)](https://nodejs.org/) +[![image](https://img.shields.io/badge/pnpm-%3E%3D%208.0.0-orange)](https://pnpm.io/) +[![image](https://img.shields.io/badge/license-MIT-yellow)](LICENSE.md) ## System dependencies -- [Node.js LTS or latest](https://nodejs.org/en/download/) -- [Java 13](https://www.azul.com/downloads/?package=jdk#download-openjdk) (other versions have some issues with Lavalink) +- [Node.js 22 or later](https://nodejs.org/en/download/) (required for `node:sqlite` and the modern toolchain) +- [Java 17+](https://www.azul.com/downloads/?package=jdk#download-openjdk) (Required for Lavalink v4 audio engine) +- [pnpm](https://pnpm.io/) (Fast, disk-efficient package manager) ## Setup bot -Create an [application.yml](https://github.com/freyacodes/lavalink/blob/master/LavalinkServer/application.yml.example) file root folder. +Create an [application.yml](application.yml.example) file in the root folder. -Download the latest Lavalink jar from [here](https://github.com/Cog-Creators/Lavalink-Jars/releases) and also place it in the root folder. +Download the latest Lavalink jar from [here](https://github.com/lavalink-devs/lavalink/releases) and place it in the root folder as `Lavalink.jar`. -### PostgreSQL +### Database & In-Memory Queue -#### Linux +Master-Bot uses **SQLite** (`/data/bot.sqlite`, via Node's built-in `node:sqlite`) and **In-Memory Audio Queues** out of the box with zero external database configuration or Redis installation required! The database file and schema are automatically created on first launch โ€” no Prisma, no schema sync, no migrations to run. Override the file location with `DISCORD_DB_PATH` if desired. -Either from the official site or follow the tutorial for your [distro](https://www.digitalocean.com/community/tutorial_collections/how-to-install-and-use-postgresql). +### Settings (.env) -#### MacOS - -Get [brew](https://brew.sh), then enter 'brew install postgresql'. - -#### Windows - -Getting Postgres and Prisma to work together on Windows is not worth the hassle. Create an account on [heroku](https://dashboard.heroku.com/apps) and follow these steps: - -1. Open the dashboard and click on 'New' > 'Create new app', give it a name and select the closest region to you then click on 'Create app'. -2. Go to 'Resources' tab, under 'Add-ons' search for 'Heroku Postgres' and select it. Click 'Submit Order Form' and then do the same step again (create another postgres instance). -3. Click on each 'Heroku Postgres' addon you created, go to 'Settings' tab > Database Credentials > View Credentials and copy the each one's URI to either `DATABASE_URL` or `SHADOW_DB_URL` in the .env file you will be creating in the settings section. -4. Done! - -### Redis - -#### MacOS - -`brew install redis`. - -#### Windows - -Download from [here](https://redis.io/download/). - -#### Linux - -Follow the instructions [here](https://redis.io/docs/getting-started/installation/install-redis-on-linux/). - -### Settings (env) - -Create a `.env` file in the root directory and copy the contents of .env.example to it. -Note: if you are not hosting postgres on Heroku you do not need the SHADOW_DB_URL variable. +Create a `.env` file in the root directory and copy the contents of `.env.example` to it. ```env -# DB URL -DATABASE_URL="postgresql://john:doe@localhost:5432/master-bot?schema=public" +# Unified Runtime Port (bot + embedded dashboard + OAuth2 share ONE port) +PORT=3000 -# Bot Token +# Discord Bot Credentials DISCORD_TOKEN="" - -NEXTAUTH_SECRET="somesupersecrettwelvelengthword" -NEXTAUTH_URL= -NEXTAUTH_URL_INTERNAL=http://localhost:3000 -NEXT_PUBLIC_INVITE_URL="https://discord.com/api/oauth2/authorize?client_id=yourclientid&permissions=8&scope=bot" - -# Next Auth Discord Provider DISCORD_CLIENT_ID="" DISCORD_CLIENT_SECRET="" +DISCORD_OWNER_ID="" -# Lavalink -LAVA_HOST="0.0.0.0" +# Lavalink v4 Audio Engine +LAVA_ENABLED=true +LAVA_HOST="127.0.0.1" LAVA_PASS="youshallnotpass" LAVA_PORT=2333 LAVA_SECURE=false -# Spotify +# Spotify Metadata SPOTIFY_CLIENT_ID="" SPOTIFY_CLIENT_SECRET="" -# Twitch +# Twitch Stream Alerts & IGDB +TWITCH_ENABLED=false TWITCH_CLIENT_ID="" TWITCH_CLIENT_SECRET="" +IGDB_ENABLED=false -# Other APIs -TENOR_API="" +# Media & Search APIs +KLIPY_API="" +GIFS_ENABLED=true +NEWS_ENABLED=false NEWS_API="" GENIUS_API="" -RAWG_API="" - ``` #### Gif features -If you have no use in the gif commands, leave everything under 'Other APIs' empty. Same applies for Twitch, everything else is needed. +If you have no use for the gif commands, leave `KLIPY_API` empty. Same applies for Twitch, News, and IGDB; everything else is needed for core music and dashboard features. -#### DB URL +#### Database -Change 'john' to your pc username and 'doe' to some password, or set the name and password you created when you installed Postgres. +The SQLite database at `/data/bot.sqlite` is auto-created and needs no setup, external server, or migration tooling. `DISCORD_DB_PATH` can point the file anywhere (e.g. a mounted volume in Docker). #### Bot Token -Generate a token in your Discord developer portal. +Generate a token in your Discord Developer Portal and paste it into `DISCORD_TOKEN`. -#### Next Auth +#### Port & URLs -You can leave everything as is, just change 'yourclientid' in NEXT_PUBLIC_INVITE_URL to your Discord bot id and then change 'domain' in NEXTAUTH_URL to your domain or public ip. You can find your public ip by going to [www.whatismyip.com](https://www.whatismyip.com/). +The bot, embedded web dashboard, and OAuth2 callback server all run in a **single process** listening on one port: +- `PORT` (default: `3000`): unified dashboard + OAuth2 callback endpoint. +- Dashboard UI: `http://localhost:3000/dashboard` +- OAuth2 redirect: `http://localhost:3000/api/auth/callback/discord` -#### Next Auth Discord Provider +The dashboard and authentication URLs are constructed automatically from `PORT`. Set `NEXTAUTH_URL` to your domain or public IP if deploying publicly. -Go to the OAuth2 tab in the developer portal, copy the Client ID to DISCORD_CLIENT_ID and generate a secret to place in DISCORD_CLIENT_SECRET. Also, set the following URLs under 'Redirects': +#### Next Auth Discord Provider -- http://localhost:3000/api/auth/callback/discord -- http://domain:3000/api/auth/callback/discord +Go to the OAuth2 tab in the Discord Developer Portal, copy the Client ID to `DISCORD_CLIENT_ID` and generate a secret to place in `DISCORD_CLIENT_SECRET`. Also, set the following URLs under 'Redirects': -Make sure to change 'domain' in http://domain:3000/api/auth/callback/discord to your domain or public ip. +- `http://localhost:3000/api/auth/callback/discord` +- `https://your-domain.com/api/auth/callback/discord` (if deploying publicly) #### Lavalink -You can leave this as long as the values match your application.yml. +Set `LAVA_PASS` and `LAVA_PORT` to match your `application.yml` file. #### Spotify and Twitch Create an application in each platform's developer portal and paste the relevant values. #### Pnpm + Install pnpm: -`npm install -g pnpm` or on Windows `iwr https://get.pnpm.io/install.ps1 -useb | iex` or on Mac using Homebrew `brew install pnpm` +`npm install -g pnpm` or on Windows `iwr https://get.pnpm.io/install.ps1 -useb | iex` or on Mac using Homebrew `brew install pnpm` # Running the bot -1. If you followed everything right, hit `pnpm i` in the root folder. When it finishes make sure prisma didn't error. -2. Open a separate terminal in the root folder and run 'java -jar Lavalink.jar' (must be running all the time). -3. Wait a few seconds and run `pnpm dev` in the root folder in another terminal window. -4. If everything works, your bot and dashboard should be running. -5. Enjoy! +1. Run `pnpm i` in the root folder to install all dependencies. +2. (Optional) Download the latest Lavalink jar and run `java -jar Lavalink.jar` โ€” must be running for music playback. The launcher can also spawn it for you when `LAVA_ENABLED=true`. +3. Run `pnpm dev` in the root folder in another terminal window. +4. If everything works, your bot and dashboard should be running (dashboard at `http://localhost:3000/dashboard`). +5. (Optional) Run the Vitest test suite with `pnpm test`. +6. Enjoy! # Commands -A full list of commands for use with Master Bot +A full list of commands for use with Master-Bot ## Music @@ -157,15 +132,18 @@ A full list of commands for use with Master Bot | /music-trivia | Engage in a music trivia with your friends. You can add more songs to the trivia pool in resources/music/musictrivia.json | /music-trivia | | /loop | Loop the currently playing song or queue | /loop | | /lyrics | Get lyrics of any song or the lyrics of the currently playing song | /lyrics song-name | -| /now-playing | Display the current playing song with a playback bar | /now-playing | -| /move | Move song to a desired position in queue | /move 8 1 | -| /queue-history | Display the queue history | /queue-history | +| /jump | Jump to a specific position in the track queue | /jump 3 | +| /seek | Seek to a specific timestamp in the current song | /seek 1:30 | +| /bassboost | Apply bassboost audio filter | /bassboost level: high | +| /nightcore | Apply nightcore audio filter | /nightcore | +| /vaporwave | Apply vaporwave audio filter | /vaporwave | +| /karaoke | Apply karaoke audio filter | /karaoke | | /create-playlist | Create a custom playlist | /create-playlist 'playlistname' | | /save-to-playlist | Add a song or playlist to a custom playlist | /save-to-playlist 'playlistname' 'yt or spotify url' | | /remove-from-playlist | Remove a track from a custom playlist | /remove-from-playlist 'playlistname' 'track location' | | /my-playlists | Display your custom playlists | /my-playlists | | /display-playlist | Display a custom playlist | /display-playlist 'playlistname' | -| /delete-playlist | remove a custom playlist | /delete-playlist 'playlistname' | +| /delete-playlist | Remove a custom playlist | /delete-playlist 'playlistname' | ## Gifs @@ -183,11 +161,29 @@ A full list of commands for use with Master Bot | /pat | Get a random pat gif | /pat | | /triggered | Get a random triggered gif | /triggered | | /amongus | Get a random Among Us gif | /amongus | +| /waifu | Get a random waifu picture | /waifu | +| /smug | Get a random smug gif | /smug | +| /kiss | Get a random kiss gif | /kiss | +| /cuddle | Get a random cuddle gif | /cuddle | + +## Moderation + +| Command | Description | Usage | +| --------- | -------------------------------------- | ---------------------- | +| /ban | Ban a user from the server | /ban @user spamming | +| /kick | Kick a user from the server | /kick @user breaking rules | +| /timeout | Timeout (mute) a user for a duration | /timeout @user 10m | +| /slowmode | Set slowmode rate limit for a channel | /slowmode 5s | +| /purge | Bulk delete a number of messages | /purge 25 | ## Other | Command | Description | Usage | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | +| /set | Configure server settings (logging, tickets, welcome messages, suggestions, etc.) | /set logs #audit-log | +| /poll | Create an interactive multi-choice poll with buttons | /poll title: "Favorite color?" | +| /reminder | Set, list, or delete personal and server reminders | /reminder set 1h "Check pizza" | +| /weather | Get current weather and forecast for any location | /weather London | | /fortune | Get a fortune cookie tip | /fortune | | /insult | Generate an evil insult | /insult | | /chucknorris | Get a satirical fact about Chuck Norris | /chucknorris | @@ -197,43 +193,43 @@ A full list of commands for use with Master Bot | /rps | Rock Paper Scissors | /rps | | /bored | Generate a random activity! | /bored | | /advice | Get some advice! | /advice | -| /game-search | Search for game information. | /game-search super-metroid | +| /connect-four | Play Connect Four interactively with buttons | /connect-four @opponent | +| /tic-tac-toe | Play Tic-Tac-Toe interactively with buttons | /tic-tac-toe @opponent | +| /game-search | Search for game information | /game-search super-metroid | +| /tv-show-search | Search for TV show information | /tv-show-search "Breaking Bad" | | /kanye | Get a random Kanye quote | /kanye | -| /world-news | Latest headlines from reuters, you can change the news source to whatever news source you want, just change the source in line 13 in world-news.js or ynet-news.js | /world-news | -| /translate | Translate to any language using Google translate.(only supported languages) | /translate english ใ‚ใ‚ŠใŒใจใ† | -| /about | Info about me and the repo | /about | -| /urban dictionary | Get definitions from urban dictionary | /urban javascript | -| /activity | Generate an invite link to your voice channel's activity | /activity voicechannel Chill | -| /twitch-status | Check the status of a Twitch steamer | /twitch-status streamer: bacon_fixation | +| /world-news | Latest headlines from world news via NewsAPI | /world-news | +| /translate | Translate to any language using Google translate (only supported languages) | /translate english ใ‚ใ‚ŠใŒใจใ† | +| /about | Info about the bot and the repository | /about | +| /urban | Get definitions from urban dictionary | /urban javascript | +| /activity | Generate an invite link to your voice channel's activity | /activity Chill | +| /twitch-status | Check the status of a Twitch streamer | /twitch-status streamer: bacon_fixation | +| /dashboard | Get the link to the web dashboard | /dashboard | +| /youtube-auth | Re-trigger YouTube OAuth Device Flow (Owner only) | /youtube-auth | ## Resources -[Getting a Tenor API key](https://developers.google.com/tenor/guides/quickstart) - -[Getting a NewsAPI API key](https://newsapi.org/) - -[Getting a Genius API key](https://genius.com/api-clients/new) - -[Getting a rawg API key](https://rawg.io/apidocs) +[Master-Bot Documentation Wiki](wiki/Home.md) -[Getting a Twitch API key](https://github.com/Bacon-Fixation/Master-Bot/wiki/Getting-Your-Twitch-API-Info) +[Getting Started & Setup Guide](wiki/Setup.md) -[Installing Node.js on Debian](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-debian-9) +[Cloud & Platform Hosting Guide](wiki/Hosting.md) -[Installing Node.js on Windows](https://treehouse.github.io/installation-guides/windows/node-windows.html) +[Lavalink v4 Audio Engine Guide](wiki/Lavalink.md) -[Installing on a Raspberry Pi](https://github.com/galnir/Master-Bot/wiki/Running-the-bot-on-a-Raspberry-Pi) +[Web Dashboard Guide](wiki/Dashboard.md) -[Using a Repl.it LavaLink server](https://github.com/galnir/Master-Bot/wiki/Setting-Up-LavaLink-with-a-Replit-server) +[Configuration & API Keys Guide](wiki/Configuration.md) -[Using a public LavaLink server](https://github.com/galnir/Master-Bot/wiki/Setting-Up-LavaLink-with-a-public-LavaLink-Server) +[Complete Commands Reference](wiki/Commands.md) -[Using an Internal LavaLink server](https://github.com/galnir/Master-Bot/wiki/Setting-up-LavaLink-with-an-Internal-LavaLink-server) +[Testing & Quality Assurance Guide](wiki/Testing.md) ## Contributing Fork it and submit a pull request! Anyone is welcome to suggest new features and improve code quality! +See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. ## Contributors โค๏ธ @@ -241,7 +237,7 @@ Anyone is welcome to suggest new features and improve code quality! [ModoSN](https://github.com/ModoSN) - 'resolve-ip', 'rps', '8ball', 'bored', 'trump', 'advice', 'kanye', 'urban dictionary' commands and visual updates -[PhantomNimbi](https://github.com/PhantomNimbi) - bring back gif commands, lavalink config tweaks +[PhantomNimbi](https://github.com/PhantomNimbi) - bring back gif commands, lavalink config tweaks, next.js 15 dashboard rewrite, vitest test suite, moderation & ticket system, cloud hosting guides [Natemo6348](https://github.com/Natemo6348) - 'mute', 'unmute' diff --git a/application.yml.example b/application.yml.example new file mode 100644 index 000000000..b30c48fbe --- /dev/null +++ b/application.yml.example @@ -0,0 +1,115 @@ +# Lavalink v4 Configuration +# Repository: https://github.com/lavalink-devs/Lavalink +# See wiki/Lavalink.md for setup and deployment guide + +server: + port: 2333 + address: 0.0.0.0 + undertow: + buffer-size: 1024 + direct-buffers: true + threads: + io: 4 + worker: 32 + +lavalink: + plugins: + - dependency: "dev.lavalink.youtube:youtube-plugin:1.18.2" + repository: "https://maven.lavalink.dev/releases" + - dependency: "com.github.topi314.lavasrc:lavasrc-plugin:4.8.3" + repository: "https://maven.topi.wtf/releases" + snapshot: false + server: + password: "youshallnotpass" + sources: + youtube: false + soundcloud: + searchEnabled: true + filterOutPreviewTracks: true + bandcamp: true + vimeo: true + nico: true + http: false + local: false + filters: + volume: true + equalizer: true + karaoke: true + timescale: true + tremolo: true + vibrato: true + distortion: true + rotation: true + channelMix: true + lowPass: true + bufferDurationMs: 400 + frameBufferDurationMs: 10000 + opusEncodingQuality: 10 + resamplingQuality: HIGH + trackStuckThresholdMs: 30000 + playersTimeout: 0 + +plugins: + youtube: + enabled: true + allowSearch: true + allowDirectVideoIds: true + allowDirectPlaylistIds: true + remoteCipher: + url: "${YOUTUBE_CIPHER_URL:https://cipher.kikkia.dev/}" + password: "${YOUTUBE_CIPHER_PASSWORD:}" + clients: + - TV + - MUSIC + - ANDROID_VR + - IOS + - WEB + - WEBEMBEDDED + clientOptions: + TV: + playback: true + videoLoading: true + playlistLoading: true + searching: true + ANDROID_VR: + playback: true + videoLoading: true + IOS: + playback: true + videoLoading: true + MUSIC: + playback: true + videoLoading: true + searching: true + WEB: + playback: true + videoLoading: true + searching: true + oauth: + enabled: true + refreshToken: "${YOUTUBE_REFRESH_TOKEN:}" + skipInitialization: "${YOUTUBE_SKIP_INIT:false}" + lavasrc: + providers: + - "ytmsearch:\"%ISRC%\"" + - "ytsearch:\"%ISRC%\"" + - "ytmsearch:%QUERY%" + - "ytsearch:%QUERY%" + - "scsearch:%QUERY%" + sources: + spotify: true + soundcloud: false + spotify: + clientId: "${SPOTIFY_CLIENT_ID:}" + clientSecret: "${SPOTIFY_CLIENT_SECRET:}" + countryCode: "US" + playlistLoadLimit: 6 + albumLoadLimit: 6 + resolveArtistsInSearch: true + +logging: + level: + root: INFO + lavalink: INFO + io.undertow.websockets.jsr: ERROR + dev.lavalink.youtube.http.YoutubeOauth2Handler: DEBUG \ No newline at end of file diff --git a/apps/bot/README.md b/apps/bot/README.md new file mode 100644 index 000000000..8c447c89b --- /dev/null +++ b/apps/bot/README.md @@ -0,0 +1,75 @@ +# ๐Ÿค– Master-Bot Discord Application (`@master-bot/bot`) + +The Discord client application for **Master-Bot**, built with [Sapphire Framework](https://www.sapphirejs.dev/), [discord.js v14](https://discord.js.org/), and [Lavalink v4 (`lavalink-client`)](https://github.com/lavalink-devs/Lavalink). Persistent data lives in a dependency-free SQLite database (`node:sqlite`, `@master-bot/db`), and the web dashboard (`@master-bot/dashboard`) is embedded directly into this process. + +--- + +## ๐Ÿ—๏ธ Architecture & Directory Structure + +```text +apps/bot/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ commands/ # 74 Sapphire chat input (slash) commands +โ”‚ โ”‚ โ”œโ”€โ”€ gifs/ # Klipy & Waifu.im reaction commands +โ”‚ โ”‚ โ”œโ”€โ”€ moderation/ # Ban, kick, purge, slowmode, timeout +โ”‚ โ”‚ โ”œโ”€โ”€ music/ # Lavalink audio playback & playlist suite +โ”‚ โ”‚ โ”œโ”€โ”€ other/ # Utilities, games, polls, reminders, news +โ”‚ โ”‚ โ””โ”€โ”€ twitch/ # Twitch status monitor +โ”‚ โ”œโ”€โ”€ lib/ # Internal business logic and class modules +โ”‚ โ”‚ โ”œโ”€โ”€ games/ # Connect 4, Tic-Tac-Toe, Rock-Paper-Scissors +โ”‚ โ”‚ โ”œโ”€โ”€ gifs/ # Media scrapers & fetchers +โ”‚ โ”‚ โ”œโ”€โ”€ music/ # Queue, Track, Lavalink node managers, NowPlaying embeds +โ”‚ โ”‚ โ”œโ”€โ”€ presence/ # Dynamic rotating presence status manager +โ”‚ โ”‚ โ”œโ”€โ”€ reminders/ # Background reminder cron scheduler +โ”‚ โ”‚ โ”œโ”€โ”€ structures/ # ExtendedClient and CommandHelp interfaces +โ”‚ โ”‚ โ””โ”€โ”€ twitch/ # Twitch token and live stream checkers +โ”‚ โ”œโ”€โ”€ listeners/ # Sapphire event listeners +โ”‚ โ”‚ โ”œโ”€โ”€ guild/ # Guild member add/remove, role updates, channel events +โ”‚ โ”‚ โ”œโ”€โ”€ interaction/ # Slash commands, autocomplete, and error handlers +โ”‚ โ”‚ โ”œโ”€โ”€ music/ # Lavalink node connection and track lifecycle events +โ”‚ โ”‚ โ””โ”€โ”€ tempchannels/ # Temporary voice channel lifecycle management +โ”‚ โ”œโ”€โ”€ preconditions/ # Sapphire preconditions (isCommandDisabled, permissions) +โ”‚ โ”œโ”€โ”€ dataService.ts # In-process facade the embedded dashboard calls +โ”‚ โ”œโ”€โ”€ server.ts # Embedded dashboard + OAuth2 callback HTTP server (PORT) +โ”‚ โ””โ”€โ”€ env.ts # Type-safe environment validation +โ”œโ”€โ”€ package.json +โ””โ”€โ”€ tsconfig.json +``` + +--- + +## โšก Key Features & Subsystems + +1. **๐ŸŽต Lavalink v4 Audio Playback**: + - YouTube multi-client failover with automated OAuth device token capture. + - Spotify metadata resolution via `lavasrc-plugin`. + - Free built-in SoundCloud track search and playback. + - Interactive channel now-playing embeds with live 5-second ASCII progress bars. + - Audio DSP filters: Bassboost, Karaoke, Nightcore, Vaporwave. +2. **๐Ÿ”จ Moderation Suite**: + - Slash commands with hierarchy safety checks and automated audit logging. +3. **๐ŸŽซ Support Tickets**: + - Thread-based ticketing system with interactive buttons (`ticket_create`, `ticket_close`) and `.txt` transcript archiving. +4. **โฐ Scheduled Reminders**: + - In-memory background scheduler checking database reminders every 30 seconds. +5. **๐Ÿ“œ Audit Logging**: + - 18 granular server event listeners routing formatted embeds to designated log channels. + +--- + +## ๐Ÿš€ Running & Building + +From the workspace root: + +```bash +# Build the bot TypeScript application +pnpm --filter @master-bot/bot build + +# Launch the bot in development watch mode (builds, copies scripts, watches) +pnpm --filter @master-bot/bot dev + +# Launch the full unified stack (Bot + embedded dashboard + optional Lavalink) +pnpm dev +``` + +The dashboard is served from the bot process itself โ€” visit `http://localhost:3000/dashboard` (or whatever `PORT` is set to). diff --git a/apps/bot/package.json b/apps/bot/package.json index 2e4b3ab65..d6b5f992f 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -6,63 +6,55 @@ "author": "Nir Gal", "license": "ISC", "main": "dist/index.js", + "type": "module", "scripts": { - "build": "pnpm with-env tsc", + "build": "tsc", "watch": "tsc --watch", - "copy-scripts": "pnpx ncp ./scripts ./dist/", + "type-check": "tsc --noEmit", + "copy-scripts": "ncp ./scripts/audio ./dist/audio", "dev": "pnpm build && pnpm copy-scripts && run-p watch start", - "start": "pnpm with-env node dist/index.js", - "with-env": "dotenv -e ../../.env --" + "start": "node dist/index.js" }, "engines": { - "node": ">=v18.16.1" + "node": ">=22.0.0" }, "dependencies": { - "@discordjs/collection": "^2.0.0", - "@lavaclient/spotify": "^3.1.0", + "@discordjs/collection": "^2.1.1", "@lavalink/encoding": "^0.1.2", - "@master-bot/api": "^0.1.0", - "@napi-rs/canvas": "^0.1.44", - "@prisma/client": "^5.6.0", - "@sapphire/decorators": "^6.0.2", - "@sapphire/discord.js-utilities": "^7.1.2", + "@master-bot/dashboard": "^1.0.0", + "@master-bot/db": "^0.1.0", + "@napi-rs/canvas": "^1.0.8", + "@sapphire/decorators": "^6.2.0", + "@sapphire/discord.js-utilities": "^7.3.3", "@sapphire/framework": "^4.8.2", "@sapphire/plugin-hmr": "^2.0.3", - "@sapphire/time-utilities": "^1.7.10", - "@sapphire/utilities": "^3.13.0", - "@t3-oss/env-core": "^0.7.1", - "@trpc/client": "next", - "@trpc/server": "next", - "axios": "^1.6.2", + "@sapphire/time-utilities": "^1.7.14", + "@sapphire/utilities": "^3.18.2", + "axios": "^1.20.0", "colorette": "^2.0.20", - "discord.js": "^14.14.1", + "discord.js": "^14.27.0", + "dotenv": "^16.6.1", "genius-discord-lyrics": "1.0.5", - "google-translate-api-x": "^10.6.7", - "ioredis": "^5.3.2", - "iso-639-1": "^3.1.0", - "lavaclient": "^4.1.1", + "google-translate-api-x": "^10.7.3", + "iso-639-1": "^3.1.6", + "lavalink-client": "2.2.0", "metadata-filter": "^1.3.0", "ncp": "^2.0.0", - "node-fetch": "^3.3.2", "npm-run-all": "^4.1.5", + "picocolors": "^1.1.0", "string-progressbar": "^1.0.4", - "superjson": "1.13.3", - "winston": "^3.11.0", - "winston-daily-rotate-file": "^4.7.1", - "zod": "^3.22.4" + "winston": "^3.19.0", + "winston-daily-rotate-file": "^5.0.0" }, "devDependencies": { - "@lavaclient/types": "^2.1.1", - "@sapphire/ts-config": "^5.0.0", - "@types/ioredis": "^4.28.10", - "@types/node": "^20.9.3", - "@typescript-eslint/eslint-plugin": "^6.12.0", - "@typescript-eslint/parser": "^6.12.0", - "dotenv": "^16.3.1", - "dotenv-cli": "^7.3.0", - "prettier": "^3.1.0", - "tslib": "^2.6.2", - "typescript": "^5.3.2" + "@sapphire/ts-config": "^5.0.3", + "@types/node": "^22.5.4", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "dotenv-cli": "^7.4.4", + "prettier": "^3.9.6", + "tslib": "^2.8.1", + "typescript": "^5.5.4" }, "eslintConfig": { "root": true, diff --git a/apps/bot/src/commands/gifs/amongus.ts b/apps/bot/src/commands/gifs/amongus.ts index a910fc2d6..880a09005 100644 --- a/apps/bot/src/commands/gifs/amongus.ts +++ b/apps/bot/src/commands/gifs/amongus.ts @@ -1,37 +1,52 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'amongus', description: 'Replies with a random Among Us gif!', preconditions: ['isCommandDisabled'] }) -export class AmongUsCommand extends Command { +export class AmongusCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=amongus&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const gifUrl = await searchGif('among us'); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' }); } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setImage(gifUrl) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'amongus', + category: 'gifs', + description: 'Replies with a random Among Us gif!', + usage: '/amongus', + examples: ['/amongus'], + options: [] +}; diff --git a/apps/bot/src/commands/gifs/anime.ts b/apps/bot/src/commands/gifs/anime.ts index 2f1bafb0f..08bf343ae 100644 --- a/apps/bot/src/commands/gifs/anime.ts +++ b/apps/bot/src/commands/gifs/anime.ts @@ -1,6 +1,8 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'anime', @@ -9,29 +11,42 @@ import { env } from '../../env'; }) export class AnimeCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=anime&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const gifUrl = await searchGif('anime'); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' }); } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setImage(gifUrl) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'anime', + category: 'gifs', + description: 'Replies with a random anime gif!', + usage: '/anime', + examples: ['/anime'], + options: [] +}; diff --git a/apps/bot/src/commands/gifs/baka.ts b/apps/bot/src/commands/gifs/baka.ts index 14053b514..611c26f9f 100644 --- a/apps/bot/src/commands/gifs/baka.ts +++ b/apps/bot/src/commands/gifs/baka.ts @@ -1,6 +1,8 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'baka', @@ -9,29 +11,57 @@ import { env } from '../../env'; }) export class BakaCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + builder.addUserOption(option => + option + .setName('target') + .setDescription('The member you want to baka (optional)') + .setRequired(false) + ); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=baka&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const target = interaction.options.getUser('target'); + const gifUrl = await searchGif('baka'); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' }); } + + const action = + target && target.id !== interaction.user.id + ? 'calls {target} a baka!'.replace('{target}', `${target}`) + : 'Replies with a random baka gif!'; + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setDescription(`โœจ ${interaction.user} ${action}`) + .setImage(gifUrl); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'baka', + category: 'gifs', + description: 'Replies with a random baka gif!', + usage: '/baka [target: @User]', + examples: ['/baka', '/baka target: @Someone'], + options: [ + { + name: 'target', + description: 'Target member to baka', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/gifs/cat.ts b/apps/bot/src/commands/gifs/cat.ts index 0f22e741f..1693f2c5b 100644 --- a/apps/bot/src/commands/gifs/cat.ts +++ b/apps/bot/src/commands/gifs/cat.ts @@ -1,37 +1,52 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'cat', - description: 'Replies with a random cat gif!', + description: 'Replies with a cute cat gif!', preconditions: ['isCommandDisabled'] }) export class CatCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=cat&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const gifUrl = await searchGif('cat'); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' }); } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setImage(gifUrl) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'cat', + category: 'gifs', + description: 'Replies with a cute cat gif!', + usage: '/cat', + examples: ['/cat'], + options: [] +}; diff --git a/apps/bot/src/commands/gifs/doggo.ts b/apps/bot/src/commands/gifs/doggo.ts index e1fb397e4..89602132c 100644 --- a/apps/bot/src/commands/gifs/doggo.ts +++ b/apps/bot/src/commands/gifs/doggo.ts @@ -1,37 +1,52 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'doggo', - description: 'Replies with a random doggo gif!', + description: 'Replies with a cute doggo gif!', preconditions: ['isCommandDisabled'] }) export class DoggoCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=doggo&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const gifUrl = await searchGif('doggo'); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' }); } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setImage(gifUrl) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'doggo', + category: 'gifs', + description: 'Replies with a cute doggo gif!', + usage: '/doggo', + examples: ['/doggo'], + options: [] +}; diff --git a/apps/bot/src/commands/gifs/gif.ts b/apps/bot/src/commands/gifs/gif.ts index f73d8ff78..44d933487 100644 --- a/apps/bot/src/commands/gifs/gif.ts +++ b/apps/bot/src/commands/gifs/gif.ts @@ -1,37 +1,65 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'gif', - description: 'Replies with a random gif gif!', + description: 'Search for any GIF or get a trending random GIF', preconditions: ['isCommandDisabled'] }) export class GifCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + builder.addStringOption(option => + option + .setName('query') + .setDescription('Search keyword for the GIF (optional)') + .setRequired(false) + ); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=gif&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const searchKeyword = interaction.options.getString('query') || 'trending'; + const gifUrl = await searchGif(searchKeyword); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: `:warning: No GIFs found for "**${searchKeyword}**".` }); } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`๐ŸŽฌ GIF: ${searchKeyword}`) + .setImage(gifUrl) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'gif', + category: 'gifs', + description: 'Search for any GIF or get a trending random GIF', + usage: '/gif [query: Keyword]', + examples: ['/gif', '/gif query: cat dance'], + options: [ + { + name: 'query', + description: 'Search keyword for the GIF', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/gifs/gintama.ts b/apps/bot/src/commands/gifs/gintama.ts index 7a9be81ff..83862bdf6 100644 --- a/apps/bot/src/commands/gifs/gintama.ts +++ b/apps/bot/src/commands/gifs/gintama.ts @@ -1,37 +1,52 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'gintama', - description: 'Replies with a random gintama gif!', + description: 'Replies with a random Gintama gif!', preconditions: ['isCommandDisabled'] }) export class GintamaCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=gintama&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const gifUrl = await searchGif('gintama'); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' }); } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setImage(gifUrl) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'gintama', + category: 'gifs', + description: 'Replies with a random Gintama gif!', + usage: '/gintama', + examples: ['/gintama'], + options: [] +}; diff --git a/apps/bot/src/commands/gifs/hug.ts b/apps/bot/src/commands/gifs/hug.ts index 819cda1b4..9710f49f1 100644 --- a/apps/bot/src/commands/gifs/hug.ts +++ b/apps/bot/src/commands/gifs/hug.ts @@ -1,37 +1,67 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'hug', - description: 'Replies with a random hug gif!', + description: 'Give someone or yourself a warm hug!', preconditions: ['isCommandDisabled'] }) export class HugCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + builder.addUserOption(option => + option + .setName('target') + .setDescription('The member you want to hug (optional)') + .setRequired(false) + ); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=hug&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const target = interaction.options.getUser('target'); + const gifUrl = await searchGif('hug'); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' }); } + + const action = + target && target.id !== interaction.user.id + ? 'gives {target} a big warm hug! ๐Ÿค—'.replace('{target}', `${target}`) + : 'Give someone or yourself a warm hug!'; + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setDescription(`โœจ ${interaction.user} ${action}`) + .setImage(gifUrl); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'hug', + category: 'gifs', + description: 'Give someone or yourself a warm hug!', + usage: '/hug [target: @User]', + examples: ['/hug', '/hug target: @Someone'], + options: [ + { + name: 'target', + description: 'Target member to hug', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/gifs/jojo.ts b/apps/bot/src/commands/gifs/jojo.ts index afa6a15ef..acb1ff1b5 100644 --- a/apps/bot/src/commands/gifs/jojo.ts +++ b/apps/bot/src/commands/gifs/jojo.ts @@ -1,37 +1,52 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'jojo', - description: 'Replies with a random jojo gif!', + description: 'Replies with a random JoJo gif!', preconditions: ['isCommandDisabled'] }) export class JojoCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=jojo&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const gifUrl = await searchGif('jojo'); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' }); } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setImage(gifUrl) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'jojo', + category: 'gifs', + description: 'Replies with a random JoJo gif!', + usage: '/jojo', + examples: ['/jojo'], + options: [] +}; diff --git a/apps/bot/src/commands/gifs/pat.ts b/apps/bot/src/commands/gifs/pat.ts new file mode 100644 index 000000000..ecb59cff0 --- /dev/null +++ b/apps/bot/src/commands/gifs/pat.ts @@ -0,0 +1,67 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; + +@ApplyOptions({ + name: 'pat', + description: 'Give someone or yourself a gentle head pat!', + preconditions: ['isCommandDisabled'] +}) +export class PatCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + builder.addUserOption(option => + option + .setName('target') + .setDescription('The member you want to pat (optional)') + .setRequired(false) + ); + return builder; + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + await interaction.deferReply(); + const target = interaction.options.getUser('target'); + const gifUrl = await searchGif('pat'); + + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' + }); + } + + const action = + target && target.id !== interaction.user.id + ? 'pats {target} on the head! ๐Ÿฅฐ'.replace('{target}', `${target}`) + : 'gives themselves a gentle head pat! ๐Ÿ˜Š'; + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setDescription(`โœจ ${interaction.user} ${action}`) + .setImage(gifUrl); + + return await interaction.editReply({ embeds: [embed] }); + } +} + +export const help: CommandHelp = { + name: 'pat', + category: 'gifs', + description: 'Give someone or yourself a gentle head pat!', + usage: '/pat [target: @User]', + examples: ['/pat', '/pat target: @Someone'], + options: [ + { + name: 'target', + description: 'Target member to pat', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/gifs/slap.ts b/apps/bot/src/commands/gifs/slap.ts index 479ab4d24..500ed51b2 100644 --- a/apps/bot/src/commands/gifs/slap.ts +++ b/apps/bot/src/commands/gifs/slap.ts @@ -1,37 +1,67 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'slap', - description: 'Replies with a random slap gif!', + description: 'Slap someone with a dramatic gif!', preconditions: ['isCommandDisabled'] }) export class SlapCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + builder.addUserOption(option => + option + .setName('target') + .setDescription('The member you want to slap (optional)') + .setRequired(false) + ); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=slap&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const target = interaction.options.getUser('target'); + const gifUrl = await searchGif('slap'); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' }); } + + const action = + target && target.id !== interaction.user.id + ? 'slaps {target}! ๐Ÿ’ฅ'.replace('{target}', `${target}`) + : 'Slap someone with a dramatic gif!'; + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setDescription(`โœจ ${interaction.user} ${action}`) + .setImage(gifUrl); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'slap', + category: 'gifs', + description: 'Slap someone with a dramatic gif!', + usage: '/slap [target: @User]', + examples: ['/slap', '/slap target: @Someone'], + options: [ + { + name: 'target', + description: 'Target member to slap', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/gifs/waifu.ts b/apps/bot/src/commands/gifs/waifu.ts index 51a3268bb..4eaedbd0c 100644 --- a/apps/bot/src/commands/gifs/waifu.ts +++ b/apps/bot/src/commands/gifs/waifu.ts @@ -1,6 +1,8 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { env } from '../../env'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif.js'; @ApplyOptions({ name: 'waifu', @@ -9,29 +11,42 @@ import { env } from '../../env'; }) export class WaifuCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); + registry.registerChatInputCommand(builder => { + builder.setName(this.name).setDescription(this.description); + return builder; + }); } public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - try { - const response = await fetch( - `https://tenor.googleapis.com/v2/search?key=${env.TENOR_API}&q=waifu&limit=1&random=true` - ); - const json = await response.json(); - if (!json.results) - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); + await interaction.deferReply(); + const gifUrl = await searchGif('waifu'); - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a GIF at this time. Please try again!' }); } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setImage(gifUrl) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }); + + return await interaction.editReply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'waifu', + category: 'gifs', + description: 'Replies with a random waifu gif!', + usage: '/waifu', + examples: ['/waifu'], + options: [] +}; diff --git a/apps/bot/src/commands/moderation/ban.ts b/apps/bot/src/commands/moderation/ban.ts new file mode 100644 index 000000000..a31114bb6 --- /dev/null +++ b/apps/bot/src/commands/moderation/ban.ts @@ -0,0 +1,209 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder, GuildMember, PermissionFlagsBits } from 'discord.js'; + +@ApplyOptions({ + name: 'ban', + description: 'Ban a member from the server.', + preconditions: ['isCommandDisabled'] +}) +export class BanCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addUserOption(opt => + opt + .setName('user') + .setDescription('The member to ban from this server') + .setRequired(true) + ) + .addStringOption(opt => + opt + .setName('reason') + .setDescription('Reason for the ban') + .setRequired(false) + .setMaxLength(500) + ) + .addIntegerOption(opt => + opt + .setName('delete-messages') + .setDescription('Purge recent messages sent by this member') + .setRequired(false) + .addChoices( + { name: "Don't delete any", value: 0 }, + { name: 'Previous 24 Hours', value: 86400 }, + { name: 'Previous 7 Days', value: 604800 } + ) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const member = interaction.member as GuildMember; + const guild = interaction.guild; + + if (!guild || !member) { + return await interaction.reply({ + content: ':x: This command can only be used in a server.', + ephemeral: true + }); + } + + if (!member.permissions.has(PermissionFlagsBits.BanMembers)) { + return await interaction.reply({ + content: + ':x: You must have the `Ban Members` permission to use this command.', + ephemeral: true + }); + } + + const botMember = guild.members.me; + if ( + !botMember || + !botMember.permissions.has(PermissionFlagsBits.BanMembers) + ) { + return await interaction.reply({ + content: + ':x: I do not have the `Ban Members` permission to execute this command.', + ephemeral: true + }); + } + + const targetUser = interaction.options.getUser('user', true); + const reason = + interaction.options.getString('reason') || 'No reason specified'; + const deleteSeconds = + interaction.options.getInteger('delete-messages') ?? 0; + + if (targetUser.id === interaction.user.id) { + return await interaction.reply({ + content: ':x: You cannot ban yourself.', + ephemeral: true + }); + } + + if (targetUser.id === botMember.id) { + return await interaction.reply({ + content: ':x: You cannot ban me with this command.', + ephemeral: true + }); + } + + if (targetUser.id === guild.ownerId) { + return await interaction.reply({ + content: ':x: You cannot ban the server owner.', + ephemeral: true + }); + } + + const targetMember = await guild.members + .fetch(targetUser.id) + .catch(() => null); + + if (targetMember) { + if ( + member.id !== guild.ownerId && + targetMember.roles.highest.position >= member.roles.highest.position + ) { + return await interaction.reply({ + content: + ':x: You cannot ban this user because their highest role is higher than or equal to yours.', + ephemeral: true + }); + } + + if ( + targetMember.roles.highest.position >= botMember.roles.highest.position + ) { + return await interaction.reply({ + content: + ':x: I cannot ban this user because their highest role is higher than or equal to my highest role.', + ephemeral: true + }); + } + + if (!targetMember.bannable) { + return await interaction.reply({ + content: ':x: This user is not bannable by the bot.', + ephemeral: true + }); + } + } + + await interaction.deferReply(); + + try { + await guild.members.ban(targetUser.id, { + deleteMessageSeconds: deleteSeconds, + reason: `${reason} | Moderator: ${interaction.user.tag}` + }); + + const embed = new EmbedBuilder() + .setTitle('๐Ÿ”จ Member Banned') + .setColor(0xed4245) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { + name: '๐Ÿ‘ค User', + value: `${targetUser.tag} (<@${targetUser.id}>)`, + inline: true + }, + { + name: '๐Ÿ›ก๏ธ Moderator', + value: `${interaction.user.tag} (<@${interaction.user.id}>)`, + inline: true + }, + { + name: '๐Ÿ“ Reason', + value: reason, + inline: false + } + ) + .setFooter({ + text: `User ID: ${targetUser.id}` + }) + .setTimestamp(); + + return await interaction.editReply({ embeds: [embed] }); + } catch (error) { + this.container.logger.error('Failed to ban user:', error); + return await interaction.editReply({ + content: ':x: An error occurred while attempting to ban this user.' + }); + } + } +} + +export const help: CommandHelp = { + name: 'ban', + category: 'moderation', + description: 'Ban a member from the server.', + usage: '/ban user: @User [reason: text] [delete-messages: 0/1/7 days]', + examples: [ + '/ban user: @User', + '/ban user: @User reason: Violating server rules', + '/ban user: @User reason: Spam delete-messages: Previous 24 Hours' + ], + options: [ + { + name: 'user', + description: 'The member to ban from this server', + required: true + }, + { + name: 'reason', + description: 'Reason for the ban', + required: false + }, + { + name: 'delete-messages', + description: 'Purge recent messages sent by this member', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/moderation/kick.ts b/apps/bot/src/commands/moderation/kick.ts new file mode 100644 index 000000000..a1d70d47b --- /dev/null +++ b/apps/bot/src/commands/moderation/kick.ts @@ -0,0 +1,192 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder, GuildMember, PermissionFlagsBits } from 'discord.js'; + +@ApplyOptions({ + name: 'kick', + description: 'Kick a member from the server.', + preconditions: ['isCommandDisabled'] +}) +export class KickCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addUserOption(opt => + opt + .setName('user') + .setDescription('The member to kick from this server') + .setRequired(true) + ) + .addStringOption(opt => + opt + .setName('reason') + .setDescription('Reason for kicking the member') + .setRequired(false) + .setMaxLength(500) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const member = interaction.member as GuildMember; + const guild = interaction.guild; + + if (!guild || !member) { + return await interaction.reply({ + content: ':x: This command can only be used in a server.', + ephemeral: true + }); + } + + if (!member.permissions.has(PermissionFlagsBits.KickMembers)) { + return await interaction.reply({ + content: + ':x: You must have the `Kick Members` permission to use this command.', + ephemeral: true + }); + } + + const botMember = guild.members.me; + if ( + !botMember || + !botMember.permissions.has(PermissionFlagsBits.KickMembers) + ) { + return await interaction.reply({ + content: + ':x: I do not have the `Kick Members` permission to execute this command.', + ephemeral: true + }); + } + + const targetUser = interaction.options.getUser('user', true); + const reason = + interaction.options.getString('reason') || 'No reason specified'; + + if (targetUser.id === interaction.user.id) { + return await interaction.reply({ + content: ':x: You cannot kick yourself.', + ephemeral: true + }); + } + + if (targetUser.id === botMember.id) { + return await interaction.reply({ + content: ':x: You cannot kick me with this command.', + ephemeral: true + }); + } + + if (targetUser.id === guild.ownerId) { + return await interaction.reply({ + content: ':x: You cannot kick the server owner.', + ephemeral: true + }); + } + + const targetMember = await guild.members + .fetch(targetUser.id) + .catch(() => null); + + if (!targetMember) { + return await interaction.reply({ + content: ':x: That user is not currently in this server.', + ephemeral: true + }); + } + + if ( + member.id !== guild.ownerId && + targetMember.roles.highest.position >= member.roles.highest.position + ) { + return await interaction.reply({ + content: + ':x: You cannot kick this user because their highest role is higher than or equal to yours.', + ephemeral: true + }); + } + + if ( + targetMember.roles.highest.position >= botMember.roles.highest.position + ) { + return await interaction.reply({ + content: + ':x: I cannot kick this user because their highest role is higher than or equal to my highest role.', + ephemeral: true + }); + } + + if (!targetMember.kickable) { + return await interaction.reply({ + content: ':x: This user is not kickable by the bot.', + ephemeral: true + }); + } + + await interaction.deferReply(); + + try { + await targetMember.kick(`${reason} | Moderator: ${interaction.user.tag}`); + + const embed = new EmbedBuilder() + .setTitle('๐Ÿ‘ข Member Kicked') + .setColor(0xf1c40f) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { + name: '๐Ÿ‘ค User', + value: `${targetUser.tag} (<@${targetUser.id}>)`, + inline: true + }, + { + name: '๐Ÿ›ก๏ธ Moderator', + value: `${interaction.user.tag} (<@${interaction.user.id}>)`, + inline: true + }, + { + name: '๐Ÿ“ Reason', + value: reason, + inline: false + } + ) + .setFooter({ + text: `User ID: ${targetUser.id}` + }) + .setTimestamp(); + + return await interaction.editReply({ embeds: [embed] }); + } catch (error) { + this.container.logger.error('Failed to kick user:', error); + return await interaction.editReply({ + content: ':x: An error occurred while attempting to kick this user.' + }); + } + } +} + +export const help: CommandHelp = { + name: 'kick', + category: 'moderation', + description: 'Kick a member from the server.', + usage: '/kick user: @User [reason: text]', + examples: [ + '/kick user: @User', + '/kick user: @User reason: Inappropriate conduct' + ], + options: [ + { + name: 'user', + description: 'The member to kick from this server', + required: true + }, + { + name: 'reason', + description: 'Reason for kicking the member', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/moderation/purge.ts b/apps/bot/src/commands/moderation/purge.ts new file mode 100644 index 000000000..6def06117 --- /dev/null +++ b/apps/bot/src/commands/moderation/purge.ts @@ -0,0 +1,134 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { + ChannelType, + GuildMember, + PermissionFlagsBits, + TextChannel +} from 'discord.js'; + +@ApplyOptions({ + name: 'purge', + description: 'Bulk delete messages from the current channel.', + preconditions: ['isCommandDisabled'] +}) +export class PurgeCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addIntegerOption(opt => + opt + .setName('amount') + .setDescription('Number of messages to delete (1 - 100)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(100) + ) + .addUserOption(opt => + opt + .setName('user') + .setDescription('Only delete messages sent by this user') + .setRequired(false) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const member = interaction.member as GuildMember; + const guild = interaction.guild; + const channel = interaction.channel as TextChannel; + + if (!guild || !member || !channel) { + return await interaction.reply({ + content: ':x: This command can only be used in a server text channel.', + ephemeral: true + }); + } + + if (!member.permissions.has(PermissionFlagsBits.ManageMessages)) { + return await interaction.reply({ + content: + ':x: You must have the `Manage Messages` permission to use this command.', + ephemeral: true + }); + } + + const botMember = guild.members.me; + if ( + !botMember || + !botMember.permissions.has(PermissionFlagsBits.ManageMessages) + ) { + return await interaction.reply({ + content: + ':x: I do not have the `Manage Messages` permission to execute this command.', + ephemeral: true + }); + } + + if (channel.type !== ChannelType.GuildText) { + return await interaction.reply({ + content: + ':x: This command can only be used in a standard text channel.', + ephemeral: true + }); + } + + const amount = interaction.options.getInteger('amount', true); + const targetUser = interaction.options.getUser('user'); + + await interaction.deferReply({ ephemeral: true }); + + try { + const fetchedMessages = await channel.messages.fetch({ limit: amount }); + + const messagesToDelete = targetUser + ? fetchedMessages.filter(m => m.author.id === targetUser.id) + : fetchedMessages; + + if (messagesToDelete.size === 0) { + return await interaction.editReply({ + content: ':warning: No matching messages found to delete.' + }); + } + + // filterOld: true automatically skips messages older than 14 days without throwing error + const deleted = await channel.bulkDelete(messagesToDelete, true); + + return await interaction.editReply({ + content: `:wastebasket: Successfully deleted **${deleted.size}** message${ + deleted.size === 1 ? '' : 's' + }${targetUser ? ` from ${targetUser.tag}` : ''}.` + }); + } catch (error) { + this.container.logger.error('Failed to purge messages:', error); + return await interaction.editReply({ + content: ':x: An error occurred while attempting to delete messages.' + }); + } + } +} + +export const help: CommandHelp = { + name: 'purge', + category: 'moderation', + description: 'Bulk delete messages from the current channel.', + usage: '/purge amount: [1-100] [user: @User]', + examples: ['/purge amount: 10', '/purge amount: 50 user: @Spammer'], + options: [ + { + name: 'amount', + description: 'Number of messages to delete (1 - 100)', + required: true + }, + { + name: 'user', + description: 'Only delete messages sent by this user', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/moderation/slowmode.ts b/apps/bot/src/commands/moderation/slowmode.ts new file mode 100644 index 000000000..dd969cccd --- /dev/null +++ b/apps/bot/src/commands/moderation/slowmode.ts @@ -0,0 +1,148 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { + ChannelType, + EmbedBuilder, + GuildMember, + PermissionFlagsBits, + TextChannel +} from 'discord.js'; + +@ApplyOptions({ + name: 'slowmode', + description: 'Set the slowmode message rate limit for a text channel.', + preconditions: ['isCommandDisabled'] +}) +export class SlowmodeCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addIntegerOption(opt => + opt + .setName('seconds') + .setDescription('Slowmode delay in seconds (0 to disable)') + .setRequired(true) + .setMinValue(0) + .setMaxValue(21600) + ) + .addChannelOption(opt => + opt + .setName('channel') + .setDescription('Target channel (defaults to current channel)') + .setRequired(false) + .addChannelTypes(ChannelType.GuildText) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const member = interaction.member as GuildMember; + const guild = interaction.guild; + + if (!guild || !member) { + return await interaction.reply({ + content: ':x: This command can only be used in a server.', + ephemeral: true + }); + } + + if (!member.permissions.has(PermissionFlagsBits.ManageChannels)) { + return await interaction.reply({ + content: + ':x: You must have the `Manage Channels` permission to use this command.', + ephemeral: true + }); + } + + const botMember = guild.members.me; + if ( + !botMember || + !botMember.permissions.has(PermissionFlagsBits.ManageChannels) + ) { + return await interaction.reply({ + content: + ':x: I do not have the `Manage Channels` permission to execute this command.', + ephemeral: true + }); + } + + const seconds = interaction.options.getInteger('seconds', true); + const targetChannel = (interaction.options.getChannel('channel') || + interaction.channel) as TextChannel; + + if (!targetChannel || targetChannel.type !== ChannelType.GuildText) { + return await interaction.reply({ + content: ':x: Target channel must be a standard text channel.', + ephemeral: true + }); + } + + await interaction.deferReply(); + + try { + await targetChannel.setRateLimitPerUser( + seconds, + `Slowmode adjusted by ${interaction.user.tag}` + ); + + const embed = new EmbedBuilder() + .setTitle('โฑ๏ธ Slowmode Updated') + .setColor(seconds > 0 ? 0x3498db : 0x2ecc71) + .addFields( + { + name: '๐Ÿ“ข Channel', + value: `<#${targetChannel.id}>`, + inline: true + }, + { + name: 'โณ Rate Limit', + value: + seconds === 0 ? '**Disabled** (0s)' : `**${seconds}s** per user`, + inline: true + }, + { + name: '๐Ÿ›ก๏ธ Moderator', + value: `${interaction.user.tag} (<@${interaction.user.id}>)`, + inline: false + } + ) + .setTimestamp(); + + return await interaction.editReply({ embeds: [embed] }); + } catch (error) { + this.container.logger.error('Failed to set slowmode:', error); + return await interaction.editReply({ + content: ':x: An error occurred while adjusting slowmode.' + }); + } + } +} + +export const help: CommandHelp = { + name: 'slowmode', + category: 'moderation', + description: 'Set the slowmode message rate limit for a text channel.', + usage: '/slowmode seconds: [0-21600] [channel: #channel]', + examples: [ + '/slowmode seconds: 5', + '/slowmode seconds: 30 channel: #general', + '/slowmode seconds: 0' + ], + options: [ + { + name: 'seconds', + description: 'Slowmode delay in seconds (0 to disable)', + required: true + }, + { + name: 'channel', + description: 'Target channel (defaults to current channel)', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/moderation/timeout.ts b/apps/bot/src/commands/moderation/timeout.ts new file mode 100644 index 000000000..f915c2d83 --- /dev/null +++ b/apps/bot/src/commands/moderation/timeout.ts @@ -0,0 +1,228 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder, GuildMember, PermissionFlagsBits } from 'discord.js'; + +@ApplyOptions({ + name: 'timeout', + description: 'Timeout (mute) a member or remove an active timeout.', + preconditions: ['isCommandDisabled'] +}) +export class TimeoutCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addUserOption(opt => + opt + .setName('user') + .setDescription('The member to timeout or unmute') + .setRequired(true) + ) + .addIntegerOption(opt => + opt + .setName('duration') + .setDescription('Timeout duration (0 to remove timeout)') + .setRequired(true) + .addChoices( + { name: 'Remove Timeout (Unmute)', value: 0 }, + { name: '1 Minute', value: 60 }, + { name: '5 Minutes', value: 300 }, + { name: '10 Minutes', value: 600 }, + { name: '1 Hour', value: 3600 }, + { name: '1 Day', value: 86400 }, + { name: '1 Week', value: 604800 } + ) + ) + .addStringOption(opt => + opt + .setName('reason') + .setDescription('Reason for the timeout') + .setRequired(false) + .setMaxLength(500) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const member = interaction.member as GuildMember; + const guild = interaction.guild; + + if (!guild || !member) { + return await interaction.reply({ + content: ':x: This command can only be used in a server.', + ephemeral: true + }); + } + + if (!member.permissions.has(PermissionFlagsBits.ModerateMembers)) { + return await interaction.reply({ + content: + ':x: You must have the `Timeout Members` permission to use this command.', + ephemeral: true + }); + } + + const botMember = guild.members.me; + if ( + !botMember || + !botMember.permissions.has(PermissionFlagsBits.ModerateMembers) + ) { + return await interaction.reply({ + content: + ':x: I do not have the `Timeout Members` permission to execute this command.', + ephemeral: true + }); + } + + const targetUser = interaction.options.getUser('user', true); + const durationSeconds = interaction.options.getInteger('duration', true); + const reason = + interaction.options.getString('reason') || 'No reason specified'; + + if (targetUser.id === interaction.user.id) { + return await interaction.reply({ + content: ':x: You cannot timeout yourself.', + ephemeral: true + }); + } + + if (targetUser.id === botMember.id) { + return await interaction.reply({ + content: ':x: You cannot timeout me with this command.', + ephemeral: true + }); + } + + if (targetUser.id === guild.ownerId) { + return await interaction.reply({ + content: ':x: You cannot timeout the server owner.', + ephemeral: true + }); + } + + const targetMember = await guild.members + .fetch(targetUser.id) + .catch(() => null); + + if (!targetMember) { + return await interaction.reply({ + content: ':x: That user is not currently in this server.', + ephemeral: true + }); + } + + if ( + member.id !== guild.ownerId && + targetMember.roles.highest.position >= member.roles.highest.position + ) { + return await interaction.reply({ + content: + ':x: You cannot timeout this user because their highest role is higher than or equal to yours.', + ephemeral: true + }); + } + + if ( + targetMember.roles.highest.position >= botMember.roles.highest.position + ) { + return await interaction.reply({ + content: + ':x: I cannot timeout this user because their highest role is higher than or equal to my highest role.', + ephemeral: true + }); + } + + if (!targetMember.moderatable) { + return await interaction.reply({ + content: ':x: This user is not moderatable by the bot.', + ephemeral: true + }); + } + + await interaction.deferReply(); + + try { + const timeoutMs = durationSeconds > 0 ? durationSeconds * 1000 : null; + await targetMember.timeout( + timeoutMs, + `${reason} | Moderator: ${interaction.user.tag}` + ); + + const embed = new EmbedBuilder() + .setTitle( + durationSeconds === 0 ? '๐Ÿ”Š Timeout Removed' : '๐Ÿ”‡ Member Timed Out' + ) + .setColor(durationSeconds === 0 ? 0x2ecc71 : 0xe67e22) + .setThumbnail(targetUser.displayAvatarURL()) + .addFields( + { + name: '๐Ÿ‘ค User', + value: `${targetUser.tag} (<@${targetUser.id}>)`, + inline: true + }, + { + name: '๐Ÿ›ก๏ธ Moderator', + value: `${interaction.user.tag} (<@${interaction.user.id}>)`, + inline: true + }, + { + name: 'โณ Duration', + value: + durationSeconds === 0 + ? '**Removed**' + : ``, + inline: true + }, + { + name: '๐Ÿ“ Reason', + value: reason, + inline: false + } + ) + .setFooter({ + text: `User ID: ${targetUser.id}` + }) + .setTimestamp(); + + return await interaction.editReply({ embeds: [embed] }); + } catch (error) { + this.container.logger.error('Failed to timeout user:', error); + return await interaction.editReply({ + content: ':x: An error occurred while adjusting member timeout.' + }); + } + } +} + +export const help: CommandHelp = { + name: 'timeout', + category: 'moderation', + description: 'Timeout (mute) a member or remove an active timeout.', + usage: '/timeout user: @User duration: [1m/5m/10m/1h/1d/1w/0] [reason: text]', + examples: [ + '/timeout user: @User duration: 5 Minutes', + '/timeout user: @User duration: 1 Hour reason: Excessive spamming', + '/timeout user: @User duration: Remove Timeout (Unmute)' + ], + options: [ + { + name: 'user', + description: 'The member to timeout or unmute', + required: true + }, + { + name: 'duration', + description: 'Timeout duration (0 to remove timeout)', + required: true + }, + { + name: 'reason', + description: 'Reason for the timeout', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/music/bassboost.ts b/apps/bot/src/commands/music/bassboost.ts index 8a558559a..6692c93ca 100644 --- a/apps/bot/src/commands/music/bassboost.ts +++ b/apps/bot/src/commands/music/bassboost.ts @@ -1,7 +1,7 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; -import type { Node, Player } from 'lavaclient'; @ApplyOptions({ name: 'bassboost', @@ -28,25 +28,41 @@ export class BassboostCommand extends Command { ) { const { client } = container; - const player = client.music.players.get( - interaction.guild!.id - ) as Player; + const player = client.music.getPlayer(interaction.guild!.id); + if (!player) + return interaction.reply({ + content: 'No active player.', + ephemeral: true + }); - player.filters.equalizer = (player.bassboost = !player.bassboost) - ? [ - { band: 0, gain: 0.55 }, - { band: 1, gain: 0.45 }, - { band: 2, gain: 0.4 }, - { band: 3, gain: 0.3 }, - { band: 4, gain: 0.15 }, - { band: 5, gain: 0 }, - { band: 6, gain: 0 } - ] - : undefined; + const enabled = !(player as any).bassboost; + (player as any).bassboost = enabled; + + if (enabled) { + await player.filterManager.setEQ([ + { band: 0, gain: 0.55 }, + { band: 1, gain: 0.45 }, + { band: 2, gain: 0.4 }, + { band: 3, gain: 0.3 }, + { band: 4, gain: 0.15 }, + { band: 5, gain: 0 }, + { band: 6, gain: 0 } + ]); + } else { + await player.filterManager.clearEQ(); + } - await player.setFilters(); return await interaction.reply( - `Bassboost ${player.bassboost ? 'enabled' : 'disabled'}` + `Bassboost ${enabled ? 'enabled' : 'disabled'}` ); } } + +export const help: CommandHelp = { + name: 'bassboost', + category: 'music', + description: 'Boost the bass of the playing track', + usage: '/bassboost', + examples: ['/bassboost'], + options: [] +}; diff --git a/apps/bot/src/commands/music/create-playlist.ts b/apps/bot/src/commands/music/create-playlist.ts index 6fa58387a..3895d848e 100644 --- a/apps/bot/src/commands/music/create-playlist.ts +++ b/apps/bot/src/commands/music/create-playlist.ts @@ -1,6 +1,7 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; -import { trpcNode } from '../../trpc'; +import { dataService } from '../../dataService.js'; @ApplyOptions({ name: 'create-playlist', @@ -33,31 +34,47 @@ export class CreatePlaylistCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); const playlistName = interaction.options.getString('playlist-name', true); const interactionMember = interaction.member?.user; if (!interactionMember) { - return await interaction.reply({ + return await interaction.editReply({ content: ':x: Something went wrong! Please try again later' }); } try { - const playlist = await trpcNode.playlist.create.mutate({ + const playlist = await dataService.playlist.create({ name: playlistName, userId: interactionMember.id }); if (!playlist) throw new Error(); } catch (error) { - await interaction.reply({ + return await interaction.editReply({ content: `:x: You already have a playlist named **${playlistName}**` }); - return; } - await interaction.reply(`Created a playlist named **${playlistName}**`); - return; + return await interaction.editReply( + `Created a playlist named **${playlistName}**` + ); } } + +export const help: CommandHelp = { + name: 'create-playlist', + category: 'music', + description: 'Create a custom playlist that you can play anytime', + usage: '/create-playlist ', + examples: ['/create-playlist playlist-name: My Favorites'], + options: [ + { + name: 'playlist-name', + description: 'What is the name of the playlist you want to create?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/music/delete-playlist.ts b/apps/bot/src/commands/music/delete-playlist.ts index 9e0ffec8d..404cab015 100644 --- a/apps/bot/src/commands/music/delete-playlist.ts +++ b/apps/bot/src/commands/music/delete-playlist.ts @@ -1,7 +1,8 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; -import { trpcNode } from '../../trpc'; -import Logger from '../../lib/logger'; +import { dataService } from '../../dataService.js'; +import Logger from '../../lib/logger.js'; @ApplyOptions({ name: 'delete-playlist', @@ -35,31 +36,48 @@ export class DeletePlaylistCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); const playlistName = interaction.options.getString('playlist-name', true); const interactionMember = interaction.member?.user; if (!interactionMember) { - return await interaction.reply( + return await interaction.editReply( ':x: Something went wrong! Please try again later' ); } try { - const playlist = await trpcNode.playlist.delete.mutate({ + const playlist = await dataService.playlist.delete({ name: playlistName, userId: interactionMember.id }); if (!playlist) throw new Error(); } catch (error) { - console.log(error); Logger.error(error); - return await interaction.reply( + return await interaction.editReply( ':x: Something went wrong! Please try again later' ); } - return await interaction.reply(`:wastebasket: Deleted **${playlistName}**`); + return await interaction.editReply( + `:wastebasket: Deleted **${playlistName}**` + ); } } + +export const help: CommandHelp = { + name: 'delete-playlist', + category: 'music', + description: 'Delete a playlist from your saved playlists', + usage: '/delete-playlist ', + examples: ['/delete-playlist playlist-name: Old Songs'], + options: [ + { + name: 'playlist-name', + description: 'What is the name of the playlist you want to delete?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/music/display-playlist.ts b/apps/bot/src/commands/music/display-playlist.ts index 08b445e8d..1b6723842 100644 --- a/apps/bot/src/commands/music/display-playlist.ts +++ b/apps/bot/src/commands/music/display-playlist.ts @@ -1,8 +1,9 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; -import { trpcNode } from '../../trpc'; +import { dataService } from '../../dataService.js'; @ApplyOptions({ name: 'display-playlist', @@ -36,17 +37,18 @@ export class DisplayPlaylistCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); const playlistName = interaction.options.getString('playlist-name', true); const interactionMember = interaction.member?.user; if (!interactionMember) { - return await interaction.reply({ + return await interaction.editReply({ content: ':x: Something went wrong! Please try again later' }); } - const playlistQuery = await trpcNode.playlist.getPlaylist.query({ + const playlistQuery = await dataService.playlist.getPlaylist({ name: playlistName, userId: interactionMember.id }); @@ -54,14 +56,14 @@ export class DisplayPlaylistCommand extends Command { const { playlist } = playlistQuery; if (!playlist) { - return await interaction.reply( + return await interaction.editReply( ':x: Something went wrong! Please try again soon' ); } const baseEmbed = new EmbedBuilder().setColor('Purple').setAuthor({ - name: interactionMember.username, - iconURL: interactionMember.avatar || undefined + name: interaction.user.username, + iconURL: interaction.user.displayAvatarURL() }); new PaginatedFieldMessageEmbed() @@ -76,3 +78,18 @@ export class DisplayPlaylistCommand extends Command { return; } } + +export const help: CommandHelp = { + name: 'display-playlist', + category: 'music', + description: 'Display a saved playlist', + usage: '/display-playlist ', + examples: ['/display-playlist playlist-name: Vibes'], + options: [ + { + name: 'playlist-name', + description: 'What is the name of the playlist you want to display?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/music/skipto.ts b/apps/bot/src/commands/music/jump.ts similarity index 51% rename from apps/bot/src/commands/music/skipto.ts rename to apps/bot/src/commands/music/jump.ts index 7496b4453..2be05395c 100644 --- a/apps/bot/src/commands/music/skipto.ts +++ b/apps/bot/src/commands/music/jump.ts @@ -1,10 +1,11 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; @ApplyOptions({ - name: 'skipto', - description: 'Skip to a track in queue', + name: 'jump', + description: 'Jump to a specific track in the queue', preconditions: [ 'GuildOnly', 'isCommandDisabled', @@ -13,7 +14,7 @@ import { container } from '@sapphire/framework'; 'inPlayerVoiceChannel' ] }) -export class SkipToCommand extends Command { +export class JumpCommand extends Command { public override registerApplicationCommands( registry: Command.Registry ): void { @@ -25,7 +26,7 @@ export class SkipToCommand extends Command { option .setName('position') .setDescription( - 'What is the position of the song you want to skip to in queue?' + 'What is the position of the song you want to jump to in the queue?' ) .setRequired(true) ) @@ -42,16 +43,38 @@ export class SkipToCommand extends Command { const length = await queue.count(); if (position > length || position < 1) { return await interaction.reply( - ':x: Please enter a valid track position.' + `:x: Please enter a valid track position between 1 and ${length}.` ); } + const targetSong = await queue.getAt(position - 1); await queue.skipTo(position); - await interaction.reply( - `:white_check_mark: Skipped to track number ${position}!` - ); + if (targetSong) { + return await interaction.reply({ + content: `:white_check_mark: Jumped to track #${position}: [**${targetSong.title}**](<${targetSong.uri}>)!`, + flags: ['SuppressEmbeds'] + }); + } - return; + return await interaction.reply( + `:white_check_mark: Jumped to track #${position}!` + ); } } + +export const help: CommandHelp = { + name: 'jump', + category: 'music', + description: 'Jump to a specific track in the queue', + usage: '/jump ', + examples: ['/jump position: 3'], + options: [ + { + name: 'position', + description: + 'What is the position of the song you want to jump to in the queue?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/music/karaoke.ts b/apps/bot/src/commands/music/karaoke.ts index c3ca1a096..4c9abc152 100644 --- a/apps/bot/src/commands/music/karaoke.ts +++ b/apps/bot/src/commands/music/karaoke.ts @@ -1,7 +1,7 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; -import type { Node, Player } from 'lavaclient'; @ApplyOptions({ name: 'karaoke', @@ -29,22 +29,27 @@ export class KaraokeCommand extends Command { ) { const { client } = container; - const player = client.music.players.get( - interaction.guild!.id - ) as Player; + const player = client.music.getPlayer(interaction.guild!.id); + if (!player) + return interaction.reply({ + content: 'No active player.', + ephemeral: true + }); - player.filters.karaoke = (player.karaoke = !player.karaoke) - ? { - level: 1, - monoLevel: 1, - filterBand: 220, - filterWidth: 100 - } - : undefined; + const enabled = await player.filterManager.toggleKaraoke(); + (player as any).karaoke = enabled; - await player.setFilters(); return await interaction.reply( - `Karaoke ${player.karaoke ? 'enabled' : 'disabled'}` + `Karaoke ${enabled ? 'enabled' : 'disabled'}` ); } } + +export const help: CommandHelp = { + name: 'karaoke', + category: 'music', + description: 'Turn the playing track to karaoke', + usage: '/karaoke', + examples: ['/karaoke'], + options: [] +}; diff --git a/apps/bot/src/commands/music/leave.ts b/apps/bot/src/commands/music/leave.ts index 036f94bcd..b9639f5e5 100644 --- a/apps/bot/src/commands/music/leave.ts +++ b/apps/bot/src/commands/music/leave.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; @@ -35,3 +36,12 @@ export class LeaveCommand extends Command { await interaction.reply({ content: 'Left the voice channel.' }); } } + +export const help: CommandHelp = { + name: 'leave', + category: 'music', + description: 'Make the bot leave its voice channel and stop playing music', + usage: '/leave', + examples: ['/leave'], + options: [] +}; diff --git a/apps/bot/src/commands/music/lyrics.ts b/apps/bot/src/commands/music/lyrics.ts index 7b1c6c8ac..3103ad715 100644 --- a/apps/bot/src/commands/music/lyrics.ts +++ b/apps/bot/src/commands/music/lyrics.ts @@ -1,10 +1,11 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; import { container } from '@sapphire/framework'; import { GeniusLyrics } from 'genius-discord-lyrics'; import { PaginatedMessage } from '@sapphire/discord.js-utilities'; -import Logger from '../../lib/logger'; +import Logger from '../../lib/logger.js'; const genius = new GeniusLyrics(process.env.GENIUS_API || ''); @@ -25,8 +26,10 @@ export class LyricsCommand extends Command { .addStringOption(option => option .setName('title') - .setDescription(':mag: What song lyrics would you like to get?') - .setRequired(true) + .setDescription( + ':mag: What song lyrics would you like to get? (optional)' + ) + .setRequired(false) ) ); } @@ -37,28 +40,30 @@ export class LyricsCommand extends Command { const { client } = container; let title = interaction.options.getString('title'); - const player = client.music.players.get(interaction.guild!.id); + const player = client.music.getPlayer(interaction.guild!.id); await interaction.deferReply(); if (!title) { - if (!player) { - return await interaction.followUp( + if (!player || !player.queue?.current) { + return await interaction.editReply( 'Please provide a valid song name or start playing one and try again!' ); } - //title = player.queue.current?.title as string; - title = 'hi'; + title = player.queue.current.info.title; } try { const lyrics = (await genius.fetchLyrics(title)) as string; + if (!lyrics || !lyrics.trim()) { + return interaction.editReply(`:x: No lyrics found for "**${title}**".`); + } const lyricsIndex = Math.round(lyrics.length / 4096) + 1; const paginatedLyrics = new PaginatedMessage({ template: new EmbedBuilder().setColor('Red').setTitle(title).setFooter({ text: 'Provided by genius.com', iconURL: - 'https://assets.genius.com/images/apple-touch-icon.png?1652977688' // Genius Lyrics Icon + 'https://assets.genius.com/images/apple-touch-icon.png?1652977688' }) }); @@ -71,13 +76,28 @@ export class LyricsCommand extends Command { } } - await interaction.followUp('Lyrics generated'); return paginatedLyrics.run(interaction); } catch (e) { Logger.error(e); - return interaction.followUp( - 'Something when wrong when trying to fetch lyrics :(' + return interaction.editReply( + 'Something went wrong when trying to fetch lyrics :(' ); } } } + +export const help: CommandHelp = { + name: 'lyrics', + category: 'music', + description: + 'Get the lyrics of any song or the lyrics of the currently playing song!', + usage: '/lyrics [title]', + examples: ['/lyrics', '/lyrics title: Bohemian Rhapsody'], + options: [ + { + name: 'title', + description: 'What song lyrics would you like to get? (optional)', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/music/move.ts b/apps/bot/src/commands/music/move.ts index 233729e11..497f44ce8 100644 --- a/apps/bot/src/commands/music/move.ts +++ b/apps/bot/src/commands/music/move.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; @@ -65,6 +66,28 @@ export class MoveCommand extends Command { } await queue.moveTracks(currentPosition - 1, newPosition - 1); - return; + return await interaction.reply( + `:twisted_right_wards_arrows: Moved track from position **#${currentPosition}** to **#${newPosition}**!` + ); } } + +export const help: CommandHelp = { + name: 'move', + category: 'music', + description: 'Move a track to a different position in queue', + usage: '/move ', + examples: ['/move current-position: 5 new-position: 1'], + options: [ + { + name: 'current-position', + description: 'What is the position of the song you want to move?', + required: true + }, + { + name: 'new-position', + description: 'What is the position you want to move the song to?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/music/music-trivia.ts b/apps/bot/src/commands/music/music-trivia.ts new file mode 100644 index 000000000..822a78796 --- /dev/null +++ b/apps/bot/src/commands/music/music-trivia.ts @@ -0,0 +1,117 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { TriviaSession } from '../../lib/music/classes/TriviaSession.js'; +import type { GuildMember, TextChannel } from 'discord.js'; + +@ApplyOptions({ + name: 'music-trivia', + description: 'Start an interactive Music Trivia game in your voice channel!', + preconditions: ['GuildOnly', 'isCommandDisabled', 'inVoiceChannel'] +}) +export class MusicTriviaCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addIntegerOption(option => + option + .setName('rounds') + .setDescription('Number of rounds (1 - 15, default: 5)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(15) + ) + .addStringOption(option => + option + .setName('category') + .setDescription('Music decade / category') + .setRequired(false) + .addChoices( + { name: 'All Categories (Mixed)', value: 'all' }, + { name: '80s Hits', value: '80s' }, + { name: '90s Hits', value: '90s' }, + { name: '2000s Hits', value: '2000s' }, + { name: '2010s Hits', value: '2010s' }, + { name: 'Modern Hits', value: 'modern' } + ) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = this.container; + const guildId = interaction.guildId!; + const member = interaction.member as GuildMember; + const voiceChannel = member?.voice?.channel; + + if (!voiceChannel) { + return await interaction.reply({ + content: + ':x: You must be connected to a voice channel to start Music Trivia!', + ephemeral: true + }); + } + + if (client.triviaSessions?.has(guildId)) { + return await interaction.reply({ + content: + ':warning: A Music Trivia session is already running in this server! Use `/stop-trivia` to end it.', + ephemeral: true + }); + } + + const queue = client.music.queues.get(guildId); + if (queue?.playing) { + return await interaction.reply({ + content: + ':warning: The music queue is currently active. Please use `/leave` or wait for the queue to finish before starting Music Trivia.', + ephemeral: true + }); + } + + const rounds = interaction.options.getInteger('rounds') || 5; + const category = interaction.options.getString('category') || 'all'; + + await interaction.reply({ + content: `๐ŸŽฎ **Music Trivia** session initialized (${rounds} rounds, category: **${category}**)! Joining <#${voiceChannel.id}>...` + }); + + const session = new TriviaSession( + guildId, + interaction.channel as TextChannel, + voiceChannel.id, + rounds, + category + ); + + if (!client.triviaSessions) client.triviaSessions = new Map(); + client.triviaSessions.set(guildId, session); + return await session.start(); + } +} + +export const help: CommandHelp = { + name: 'music-trivia', + category: 'music', + description: 'Start an interactive Music Trivia game in your voice channel!', + usage: '/music-trivia [rounds] [category]', + examples: ['/music-trivia', '/music-trivia rounds: 10 category: 90s'], + options: [ + { + name: 'rounds', + description: 'Number of rounds (1 - 15, default: 5)', + required: false + }, + { + name: 'category', + description: 'Music category / era', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/music/my-playlists.ts b/apps/bot/src/commands/music/my-playlists.ts index 5e1eaeec2..d732a2f18 100644 --- a/apps/bot/src/commands/music/my-playlists.ts +++ b/apps/bot/src/commands/music/my-playlists.ts @@ -1,18 +1,14 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; import { EmbedBuilder } from 'discord.js'; -import { trpcNode } from '../../trpc'; +import { dataService } from '../../dataService.js'; @ApplyOptions({ name: 'my-playlists', description: "Display your custom playlists' names", - preconditions: [ - 'GuildOnly', - 'isCommandDisabled', - 'inVoiceChannel', - 'userInDB' - ] + preconditions: ['GuildOnly', 'isCommandDisabled', 'userInDB'] }) export class MyPlaylistsCommand extends Command { public override registerApplicationCommands( @@ -27,25 +23,26 @@ export class MyPlaylistsCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); const interactionMember = interaction.member?.user; if (!interactionMember) { - return await interaction.reply({ + return await interaction.editReply({ content: ':x: Something went wrong! Please try again later' }); } const baseEmbed = new EmbedBuilder().setColor('Purple').setAuthor({ - name: `${interactionMember.username}`, - iconURL: interactionMember.avatar || undefined + name: interaction.user.username, + iconURL: interaction.user.displayAvatarURL() }); - const playlistsQuery = await trpcNode.playlist.getAll.query({ + const playlistsQuery = await dataService.playlist.getAll({ userId: interactionMember.id }); if (!playlistsQuery || !playlistsQuery.playlists.length) { - return await interaction.reply(':x: You have no custom playlists'); + return await interaction.editReply(':x: You have no custom playlists'); } new PaginatedFieldMessageEmbed() @@ -60,3 +57,12 @@ export class MyPlaylistsCommand extends Command { return; } } + +export const help: CommandHelp = { + name: 'my-playlists', + category: 'music', + description: 'Display your custom playlists', + usage: '/my-playlists', + examples: ['/my-playlists'], + options: [] +}; diff --git a/apps/bot/src/commands/music/nightcore.ts b/apps/bot/src/commands/music/nightcore.ts index 1c295f46f..6e942585a 100644 --- a/apps/bot/src/commands/music/nightcore.ts +++ b/apps/bot/src/commands/music/nightcore.ts @@ -1,7 +1,7 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; -import type { Node, Player } from 'lavaclient'; @ApplyOptions({ name: 'nightcore', @@ -29,17 +29,27 @@ export class NightcoreCommand extends Command { ) { const { client } = container; - const player = client.music.players.get( - interaction.guild!.id - ) as Player; + const player = client.music.getPlayer(interaction.guild!.id); + if (!player) + return interaction.reply({ + content: 'No active player.', + ephemeral: true + }); - player.filters.timescale = (player.nightcore = !player.nightcore) - ? { speed: 1.125, pitch: 1.125, rate: 1 } - : undefined; + const enabled = await player.filterManager.toggleNightcore(); + (player as any).nightcore = enabled; - await player.setFilters(); return await interaction.reply( - `Nightcore ${player.nightcore ? 'enabled' : 'disabled'}` + `Nightcore ${enabled ? 'enabled' : 'disabled'}` ); } } + +export const help: CommandHelp = { + name: 'nightcore', + category: 'music', + description: 'Enable/Disable Nightcore filter', + usage: '/nightcore', + examples: ['/nightcore'], + options: [] +}; diff --git a/apps/bot/src/commands/music/pause.ts b/apps/bot/src/commands/music/pause.ts index 424043e12..243b0e185 100644 --- a/apps/bot/src/commands/music/pause.ts +++ b/apps/bot/src/commands/music/pause.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; @@ -33,3 +34,12 @@ export class PauseCommand extends Command { await queue.pause(interaction); } } + +export const help: CommandHelp = { + name: 'pause', + category: 'music', + description: 'Pause the music', + usage: '/pause', + examples: ['/pause'], + options: [] +}; diff --git a/apps/bot/src/commands/music/play.ts b/apps/bot/src/commands/music/play.ts index 7b914fa4b..cf3bae07f 100644 --- a/apps/bot/src/commands/music/play.ts +++ b/apps/bot/src/commands/music/play.ts @@ -1,9 +1,11 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; -import searchSong from '../../lib/music/searchSong'; -import type { Song } from '../../lib/music/classes/Song'; -import { trpcNode } from '../../trpc'; +import searchSong from '../../lib/music/searchSong.js'; +import { updatePlayerEmbed } from '../../lib/music/buttonHandler.js'; +import { Song } from '../../lib/music/classes/Song.js'; +import { dataService } from '../../dataService.js'; import { GuildMember } from 'discord.js'; @ApplyOptions({ @@ -68,7 +70,14 @@ export class PlayCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - await interaction.deferReply(); + await interaction.deferReply().catch(() => {}); + + const reply = async (payload: any) => { + if (interaction.deferred || interaction.replied) { + return await interaction.editReply(payload).catch(() => {}); + } + return await interaction.reply(payload).catch(() => {}); + }; const { client } = container; @@ -81,9 +90,7 @@ export class PlayCommand extends Command { const interactionMember = interaction.member?.user; if (!interactionMember) { - return await interaction.followUp( - ':x: Something went wrong! Please try again later' - ); + return await reply(':x: Something went wrong! Please try again later'); } const { music } = client; @@ -92,7 +99,7 @@ export class PlayCommand extends Command { // edge case - someone initiated the command but left the voice channel if (!voiceChannel) { - return interaction.followUp({ + return await reply({ content: ':x: You need to be in a voice channel to use this command!' }); } @@ -100,16 +107,15 @@ export class PlayCommand extends Command { let queue = music.queues.get(interaction.guildId!); await queue.setTextChannelID(interaction.channel!.id); - if (!queue.player) { - const player = queue.createPlayer(); - await player.connect(voiceChannel.id, { deafened: true }); + if (!queue.player || !queue.player.connected) { + await queue.connect(voiceChannel.id); } let tracks: Song[] = []; let message: string = ''; if (isCustomPlaylist == 'Yes') { - const data = await trpcNode.playlist.getPlaylist.query({ + const data = await dataService.playlist.getPlaylist({ userId: interactionMember.id, name: query }); @@ -117,41 +123,71 @@ export class PlayCommand extends Command { const { playlist } = data; if (!playlist) { - return await interaction.followUp(`:x: You have no such playlist!`); + return await reply(`:x: You have no such playlist!`); } if (!playlist.songs.length) { - return await interaction.followUp(`:x: **${query}** is empty!`); + return await reply(`:x: **${query}** is empty!`); } const { songs } = playlist; - tracks.push(...songs); - message = `Added songs from **${playlist}** to the queue!`; + tracks.push(...songs.map(song => new Song(song))); + message = `Added songs from **${playlist.name}** to the queue!`; } else { const trackTuple = await searchSong(query, interaction.user); if (!trackTuple[1].length) { - return await interaction.followUp({ content: trackTuple[0] as string }); // error + return await reply({ content: trackTuple[0] as string }); } message = trackTuple[0]; tracks.push(...trackTuple[1]); } + const currentTrack = await queue.getCurrentTrack(); + const isPlaying = Boolean(currentTrack); + await queue.add(tracks); if (shufflePlaylist == 'Yes') { await queue.shuffleTracks(); } - const current = await queue.getCurrentTrack(); - if (current) { - client.emit( - 'musicSongPlayMessage', - interaction.channel, - await queue.getCurrentTrack() - ); - return; + if (isPlaying) { + await updatePlayerEmbed(queue); + return await reply({ + content: message, + flags: ['SuppressEmbeds'] + }); } - queue.start(); - - return await interaction.followUp({ content: message }); + await queue.next(); + return await reply({ + content: message, + flags: ['SuppressEmbeds'] + }); } } + +export const help: CommandHelp = { + name: 'play', + category: 'music', + description: 'Play any song or playlist from YouTube, Spotify and more!', + usage: '/play [is-custom-playlist] [shuffle-playlist]', + examples: [ + '/play query: value is-custom-playlist: value shuffle-playlist: value' + ], + options: [ + { + name: 'query', + description: 'What song or playlist would you like to listen to?', + required: true + }, + { + name: 'is-custom-playlist', + description: 'Is it a custom playlist?', + required: false + }, + { + name: 'shuffle-playlist', + description: 'Would you like to shuffle the playlist?', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/music/queue.ts b/apps/bot/src/commands/music/queue.ts index 9554da6d3..a7e4623a3 100644 --- a/apps/bot/src/commands/music/queue.ts +++ b/apps/bot/src/commands/music/queue.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -48,3 +49,12 @@ export class QueueCommand extends Command { .run(interaction); } } + +export const help: CommandHelp = { + name: 'queue', + category: 'music', + description: 'Get a List of the Music Queue', + usage: '/queue', + examples: ['/queue'], + options: [] +}; diff --git a/apps/bot/src/commands/music/remove-from-playlist.ts b/apps/bot/src/commands/music/remove-from-playlist.ts index 7e71c83ec..964980d58 100644 --- a/apps/bot/src/commands/music/remove-from-playlist.ts +++ b/apps/bot/src/commands/music/remove-from-playlist.ts @@ -1,6 +1,7 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; -import { trpcNode } from '../../trpc'; +import { dataService } from '../../dataService.js'; @ApplyOptions({ name: 'remove-from-playlist', @@ -49,46 +50,67 @@ export class RemoveFromPlaylistCommand extends Command { const interactionMember = interaction.member?.user; if (!interactionMember) { - return await interaction.followUp( + return await interaction.editReply( ':x: Something went wrong! Please try again later' ); } let playlist; try { - const playlistQuery = await trpcNode.playlist.getPlaylist.query({ + const playlistQuery = await dataService.playlist.getPlaylist({ name: playlistName, userId: interactionMember.id }); playlist = playlistQuery.playlist; } catch (error) { - return await interaction.followUp(':x: Something went wrong!'); + return await interaction.editReply(':x: Something went wrong!'); } const songs = playlist?.songs; if (!songs?.length) { - return await interaction.followUp(`:x: **${playlistName}** is empty!`); + return await interaction.editReply(`:x: **${playlistName}** is empty!`); } - if (location > songs.length || location < 0) { - return await interaction.followUp(':x: Please enter a valid index!'); + if (location > songs.length || location < 1) { + return await interaction.editReply(':x: Please enter a valid index!'); } const id = songs[location - 1].id; - const song = await trpcNode.song.delete.mutate({ + const song = await dataService.song.delete({ id }); - if (!song) { - return await interaction.followUp(':x: Something went wrong!'); + if (!song?.song) { + return await interaction.editReply(':x: Something went wrong!'); } - await interaction.followUp( + await interaction.editReply( `:wastebasket: Deleted **${song.song.title}** from **${playlistName}**` ); return; } } + +export const help: CommandHelp = { + name: 'remove-from-playlist', + category: 'music', + description: 'Remove a song from a saved playlist', + usage: '/remove-from-playlist ', + examples: ['/remove-from-playlist playlist-name: Vibes location: 1'], + options: [ + { + name: 'playlist-name', + description: 'What is the name of the playlist you want to remove from?', + required: true + }, + { + name: 'location', + description: + 'What is the index of the video you would like to delete from your saved playlist?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/music/remove.ts b/apps/bot/src/commands/music/remove.ts index e62cdeede..b78d94ba0 100644 --- a/apps/bot/src/commands/music/remove.ts +++ b/apps/bot/src/commands/music/remove.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; @@ -50,3 +51,19 @@ export class RemoveCommand extends Command { }); } } + +export const help: CommandHelp = { + name: 'remove', + category: 'music', + description: 'Remove a track from the queue', + usage: '/remove ', + examples: ['/remove position: value'], + options: [ + { + name: 'position', + description: + 'What is the position of the song you want to remove from the queue?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/music/resume.ts b/apps/bot/src/commands/music/resume.ts index 9e2195ce3..2a8f81179 100644 --- a/apps/bot/src/commands/music/resume.ts +++ b/apps/bot/src/commands/music/resume.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; @@ -33,3 +34,12 @@ export class ResumeCommand extends Command { await queue.resume(interaction); } } + +export const help: CommandHelp = { + name: 'resume', + category: 'music', + description: 'Resume the music', + usage: '/resume', + examples: ['/resume'], + options: [] +}; diff --git a/apps/bot/src/commands/music/save-to-playlist.ts b/apps/bot/src/commands/music/save-to-playlist.ts index f0c5ac576..c1d6acc09 100644 --- a/apps/bot/src/commands/music/save-to-playlist.ts +++ b/apps/bot/src/commands/music/save-to-playlist.ts @@ -1,8 +1,9 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; -import searchSong from '../../lib/music/searchSong'; -import { trpcNode } from '../../trpc'; -import Logger from '../../lib/logger'; +import searchSong from '../../lib/music/searchSong.js'; +import { dataService } from '../../dataService.js'; +import Logger from '../../lib/logger.js'; @ApplyOptions({ name: 'save-to-playlist', @@ -49,48 +50,75 @@ export class SaveToPlaylistCommand extends Command { const interactionMember = interaction.member?.user; if (!interactionMember) { - return await interaction.followUp( + return await interaction.editReply( ':x: Something went wrong! Please try again later' ); } - const playlistQuery = await trpcNode.playlist.getPlaylist.query({ + const playlistQuery = await dataService.playlist.getPlaylist({ name: playlistName, userId: interactionMember.id }); if (!playlistQuery.playlist) { - return await interaction.followUp('Playlist does not exist'); + return await interaction.editReply('Playlist does not exist'); } const playlistId = playlistQuery.playlist.id; const songTuple = await searchSong(url, interaction.user); if (!songTuple[1].length) { - return await interaction.followUp(songTuple[0]); + return await interaction.editReply(songTuple[0]); } const songArray = songTuple[1]; - const songsToAdd: any[] = []; - - for (let i = 0; i < songArray.length; i++) { - const song = songArray[i]; - delete song['requester']; - songsToAdd.push({ - ...song, - playlistId: +playlistId - }); - } + const songsToAdd = songArray.map((song: any) => ({ + length: song.length || 0, + track: song.track || '', + identifier: song.identifier || '', + author: song.author || 'Unknown', + isStream: Boolean(song.isStream), + position: song.position || 0, + title: song.title || 'Untitled', + uri: song.uri || '', + isSeekable: Boolean(song.isSeekable), + sourceName: song.sourceName || 'youtube', + thumbnail: song.thumbnail || '', + added: Date.now(), + playlistId: Number(playlistId) + })); try { - await trpcNode.song.createMany.mutate({ + await dataService.song.createMany({ songs: songsToAdd }); - return await interaction.followUp(`Added tracks to **${playlistName}**`); + return await interaction.editReply(`Added tracks to **${playlistName}**`); } catch (error) { Logger.error(error); - return await interaction.followUp(':x: Something went wrong!'); + return await interaction.editReply(':x: Something went wrong!'); } } } + +export const help: CommandHelp = { + name: 'save-to-playlist', + category: 'music', + description: 'Save a song or a playlist to a custom playlist', + usage: '/save-to-playlist ', + examples: [ + '/save-to-playlist playlist-name: Vibes url: https://youtube.com/...' + ], + options: [ + { + name: 'playlist-name', + description: 'What is the name of the playlist you want to save to?', + required: true + }, + { + name: 'url', + description: 'What do you want to save to the custom playlist?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/music/seek.ts b/apps/bot/src/commands/music/seek.ts index a8878ac62..c96e84c57 100644 --- a/apps/bot/src/commands/music/seek.ts +++ b/apps/bot/src/commands/music/seek.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; @@ -56,3 +57,19 @@ export class SeekCommand extends Command { return await interaction.reply(`Seeked to ${seconds} seconds`); } } + +export const help: CommandHelp = { + name: 'seek', + category: 'music', + description: 'Seek to a desired point in a track', + usage: '/seek ', + examples: ['/seek seconds: value'], + options: [ + { + name: 'seconds', + description: + 'To what point in the track do you want to seek? (in seconds)', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/music/shuffle.ts b/apps/bot/src/commands/music/shuffle.ts index 319402c5a..0729e59ce 100644 --- a/apps/bot/src/commands/music/shuffle.ts +++ b/apps/bot/src/commands/music/shuffle.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; @@ -39,3 +40,12 @@ export class LeaveCommand extends Command { return await interaction.reply(':white_check_mark: Shuffled queue!'); } } + +export const help: CommandHelp = { + name: 'shuffle', + category: 'music', + description: 'Shuffle the music queue', + usage: '/shuffle', + examples: ['/shuffle'], + options: [] +}; diff --git a/apps/bot/src/commands/music/skip.ts b/apps/bot/src/commands/music/skip.ts deleted file mode 100644 index af54626c9..000000000 --- a/apps/bot/src/commands/music/skip.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command, CommandOptions } from '@sapphire/framework'; -import { container } from '@sapphire/framework'; - -@ApplyOptions({ - name: 'skip', - description: 'Skip the current song playing', - preconditions: [ - 'GuildOnly', - 'isCommandDisabled', - 'inVoiceChannel', - 'playerIsPlaying', - 'inPlayerVoiceChannel' - ] -}) -export class SkipCommand extends Command { - public override registerApplicationCommands( - registry: Command.Registry - ): void { - registry.registerChatInputCommand({ - name: this.name, - description: this.description - }); - } - - public override async chatInputRun( - interaction: Command.ChatInputCommandInteraction - ) { - const { client } = container; - const { music } = client; - const queue = music.queues.get(interaction.guildId!); - - const track = await queue.getCurrentTrack(); - await queue.next({ skipped: true }); - - client.emit('musicSongSkipNotify', interaction, track); - - return; - } -} diff --git a/apps/bot/src/commands/music/stop-trivia.ts b/apps/bot/src/commands/music/stop-trivia.ts new file mode 100644 index 000000000..8925d805f --- /dev/null +++ b/apps/bot/src/commands/music/stop-trivia.ts @@ -0,0 +1,48 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; + +@ApplyOptions({ + name: 'stop-trivia', + description: 'Stop the active Music Trivia game in this server', + preconditions: ['GuildOnly', 'isCommandDisabled', 'inVoiceChannel'] +}) +export class StopTriviaCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = this.container; + const guildId = interaction.guildId!; + + const session = client.triviaSessions?.get(guildId); + if (!session || session.isEnded) { + return await interaction.reply({ + content: + ':x: There is no active Music Trivia session running in this server.', + ephemeral: true + }); + } + + await session.stop(`Ended by ${interaction.user.username}`); + return await interaction.reply({ + content: ':octagonal_sign: Stopped the active Music Trivia game.' + }); + } +} + +export const help: CommandHelp = { + name: 'stop-trivia', + category: 'music', + description: 'Stop the active Music Trivia game in this server', + usage: '/stop-trivia', + examples: ['/stop-trivia'], + options: [] +}; diff --git a/apps/bot/src/commands/music/vaporwave.ts b/apps/bot/src/commands/music/vaporwave.ts index e48f2640a..6f50282fa 100644 --- a/apps/bot/src/commands/music/vaporwave.ts +++ b/apps/bot/src/commands/music/vaporwave.ts @@ -1,7 +1,7 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; -import type { Node, Player } from 'lavaclient'; @ApplyOptions({ name: 'vaporwave', @@ -29,30 +29,27 @@ export class VaporWaveCommand extends Command { ) { const { client } = container; - const player = client.music.players.get( - interaction.guild!.id - ) as Player; + const player = client.music.getPlayer(interaction.guild!.id); + if (!player) + return interaction.reply({ + content: 'No active player.', + ephemeral: true + }); - player.filters = (player.vaporwave = !player.vaporwave) - ? { - ...player.filters, - equalizer: [ - { band: 1, gain: 0.7 }, - { band: 0, gain: 0.6 } - ], - timescale: { pitch: 0.7, speed: 1, rate: 1 }, - tremolo: { depth: 0.6, frequency: 14 } - } - : { - ...player.filters, - equalizer: undefined, - timescale: undefined, - tremolo: undefined - }; + const enabled = await player.filterManager.toggleVaporwave(); + (player as any).vaporwave = enabled; - await player.setFilters(); return await interaction.reply( - `Vaporwave ${player.vaporwave ? 'enabled' : 'disabled'}` + `Vaporwave ${enabled ? 'enabled' : 'disabled'}` ); } } + +export const help: CommandHelp = { + name: 'vaporwave', + category: 'music', + description: 'Apply vaporwave on the playing track!', + usage: '/vaporwave', + examples: ['/vaporwave'], + options: [] +}; diff --git a/apps/bot/src/commands/music/volume.ts b/apps/bot/src/commands/music/volume.ts index 23d4e3b87..889ad2c97 100644 --- a/apps/bot/src/commands/music/volume.ts +++ b/apps/bot/src/commands/music/volume.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { container } from '@sapphire/framework'; @@ -49,3 +50,18 @@ export class VolumeCommand extends Command { ); } } + +export const help: CommandHelp = { + name: 'volume', + category: 'music', + description: 'Set the Volume', + usage: '/volume ', + examples: ['/volume setting: value'], + options: [ + { + name: 'setting', + description: 'What Volume? (0 to 200)', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/music/youtube-auth.ts b/apps/bot/src/commands/music/youtube-auth.ts new file mode 100644 index 000000000..5e3b66f83 --- /dev/null +++ b/apps/bot/src/commands/music/youtube-auth.ts @@ -0,0 +1,99 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { + getApplicationOwnerUser, + initiateDeviceFlow, + pollForRefreshToken +} from '../../lib/music/youtubeOAuth'; + +@ApplyOptions({ + name: 'youtube-auth', + description: 'Authorize YouTube playback via Device Flow (Owner Only)', + preconditions: ['GuildOnly', 'isCommandDisabled'] +}) +export class YoutubeAuthCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder.setName(this.name).setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const { client } = this.container; + const ownerUser = await getApplicationOwnerUser(client); + + if (ownerUser && interaction.user.id !== ownerUser.id) { + return await interaction.reply({ + content: ':x: This command is restricted to the bot owner.', + ephemeral: true + }); + } + + await interaction.deferReply({ ephemeral: true }); + + try { + const flow = await initiateDeviceFlow(); + + const embed = new EmbedBuilder() + .setTitle('๐Ÿ”‘ YouTube OAuth Device Authorization') + .setColor('Yellow') + .setDescription( + `Please authorize YouTube playback for Master-Bot:\n\n` + + `**Step 1:** Visit [${flow.verification_url}](${flow.verification_url})\n` + + `**Step 2:** Enter Code: \`${flow.user_code}\`\n\n` + + `*Waiting for browser authorization... (Expires in ${Math.round(flow.expires_in / 60)} minutes)*` + ) + .setTimestamp(); + + await interaction.editReply({ embeds: [embed] }); + + const refreshToken = await pollForRefreshToken( + flow.device_code, + flow.interval, + flow.expires_in + ); + + if (refreshToken) { + const successEmbed = new EmbedBuilder() + .setTitle('โœ… YouTube Authorization Successful') + .setColor('Green') + .setDescription( + `YouTube Audio playback has been successfully authorized!\n` + + `The refresh token has been automatically saved to \`.youtube-oauth.json\`.` + ) + .setTimestamp(); + + return await interaction.editReply({ embeds: [successEmbed] }); + } else { + const failEmbed = new EmbedBuilder() + .setTitle('โŒ YouTube Authorization Timed Out') + .setColor('Red') + .setDescription( + `Authorization timed out or was denied. Please run \`/youtube-auth\` again.` + ) + .setTimestamp(); + + return await interaction.editReply({ embeds: [failEmbed] }); + } + } catch (err: any) { + return await interaction.editReply({ + content: `:x: Failed to initiate YouTube device flow: ${err?.message || err}` + }); + } + } +} + +export const help: CommandHelp = { + name: 'youtube-auth', + category: 'music', + description: 'Authorize YouTube playback via Device Flow (Owner Only)', + usage: '/youtube-auth', + examples: ['/youtube-auth'], + options: [] +}; diff --git a/apps/bot/src/commands/other/8ball.ts b/apps/bot/src/commands/other/8ball.ts index 56a30706d..7f994f286 100644 --- a/apps/bot/src/commands/other/8ball.ts +++ b/apps/bot/src/commands/other/8ball.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -70,3 +71,18 @@ const answers = [ 'You can rely on it.', 'As I see it, yes.' ]; + +export const help: CommandHelp = { + name: '8ball', + category: 'other', + description: 'Get the answer to anything!', + usage: '/8ball ', + examples: ['/8ball question: value'], + options: [ + { + name: 'question', + description: 'The question you want to ask the 8ball', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/about.ts b/apps/bot/src/commands/other/about.ts index a4202ceed..e40652b3d 100644 --- a/apps/bot/src/commands/other/about.ts +++ b/apps/bot/src/commands/other/about.ts @@ -1,31 +1,356 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { EmbedBuilder } from 'discord.js'; +import { Command, container } from '@sapphire/framework'; +import { + ChannelType, + EmbedBuilder, + GuildMember, + type ChatInputCommandInteraction, + type Guild +} from 'discord.js'; + +const REPO_URL = 'https://github.com/galnir/Master-Bot'; +const SUPPORT_DISCORD = 'https://discord.gg/master-bot'; + +function formatUptime(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + parts.push(`${seconds}s`); + return parts.join(' '); +} + +function formatDate(date: Date): string { + return date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'long', + year: 'numeric' + }); +} + +function countOnlineMembers(guild: Guild): number { + let online = 0; + for (const member of guild.members.cache.values()) { + if ( + member.presence?.status === 'online' || + member.presence?.status === 'idle' || + member.presence?.status === 'dnd' + ) { + online++; + } + } + return online; +} + +function guildRoleId(guild: Guild): string { + return guild.roles.everyone.id; +} @ApplyOptions({ name: 'about', - description: 'Display info about the bot!', + description: 'Display detailed information about the bot, server, or a user', preconditions: ['isCommandDisabled'] }) export class AboutCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { registry.registerChatInputCommand(builder => - builder // + builder .setName(this.name) .setDescription(this.description) + .addSubcommand(subcommand => + subcommand + .setName('bot') + .setDescription('Display detailed information about Master-Bot') + ) + .addSubcommand(subcommand => + subcommand + .setName('server') + .setDescription('Display detailed information about this server') + ) + .addSubcommand(subcommand => + subcommand + .setName('user') + .setDescription('Display detailed information about a user') + .addUserOption(option => + option + .setName('user') + .setDescription( + 'The user to get information about (defaults to you if omitted)' + ) + .setRequired(false) + ) + ) ); } - public override async chatInputRun( - interaction: Command.ChatInputCommandInteraction - ) { - const embed = new EmbedBuilder() - .setTitle('About') - .setDescription( - 'A Discord bot with slash commands, playlist support, Spotify, music quiz, saved playlists, lyrics, gifs and more.\n\n :white_small_square: [Commands](https://github.com/galnir/Master-Bot#commands)\n :white_small_square: [Contributors](https://github.com/galnir/Master-Bot#contributors-%EF%B8%8F)' - ) - .setColor('Aqua'); - - return interaction.reply({ embeds: [embed] }); + public override async chatInputRun(interaction: ChatInputCommandInteraction) { + await interaction.deferReply(); + const { client } = container; + const subcommand = interaction.options.getSubcommand(false); + + if (subcommand === 'server') { + if (!interaction.inGuild() || !interaction.guild) { + return interaction.editReply({ + content: + ':information_source: The server subcommand can only be used inside a server.' + }); + } + + const guild = interaction.guild; + const owner = await guild.fetchOwner().catch(() => null); + const textChannels = guild.channels.cache.filter( + channel => channel.type === ChannelType.GuildText + ).size; + const voiceChannels = guild.channels.cache.filter( + channel => channel.type === ChannelType.GuildVoice + ).size; + const categoryChannels = guild.channels.cache.filter( + channel => channel.type === ChannelType.GuildCategory + ).size; + + const embed = new EmbedBuilder() + .setTitle(guild.name) + .setThumbnail(guild.iconURL({ size: 256 }) || null) + .setColor('Blue') + .setDescription('Here is some information about this server.') + .addFields( + { + name: '๐Ÿ‘‘ Owner', + value: owner ? owner.user.tag : 'Unknown', + inline: true + }, + { + name: '๐Ÿ‘ฅ Members', + value: guild.memberCount.toLocaleString(), + inline: true + }, + { + name: '๐ŸŸข Online', + value: countOnlineMembers(guild).toLocaleString(), + inline: true + }, + { + name: '๐Ÿ“ Channels', + value: `${textChannels} text โ€ข ${voiceChannels} voice โ€ข ${categoryChannels} category`, + inline: true + }, + { + name: '๐ŸŽญ Roles', + value: guild.roles.cache.size.toLocaleString(), + inline: true + }, + { + name: '๐Ÿš€ Boosts', + value: `${guild.premiumSubscriptionCount} (Level ${guild.premiumTier})`, + inline: true + }, + { + name: '๐Ÿ—“๏ธ Created', + value: formatDate(guild.createdAt), + inline: true + }, + { + name: '๐Ÿ†” ID', + value: guild.id, + inline: true + }, + { + name: '๐ŸŒ Locale', + value: guild.preferredLocale || 'Unknown', + inline: true + } + ) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }) + .setTimestamp(); + + return interaction.editReply({ embeds: [embed] }); + } else if (subcommand === 'user') { + const targetUser = + interaction.options.getUser('user') || interaction.user; + let member: GuildMember | null = null; + if (interaction.inGuild() && interaction.guild) { + member = await interaction.guild.members + .fetch(targetUser.id) + .catch(() => null); + } + + const embed = new EmbedBuilder() + .setTitle(targetUser.tag) + .setThumbnail(targetUser.displayAvatarURL({ size: 256 })) + .setColor(member?.displayColor || 'Green') + .setDescription( + `Here is some information about **${targetUser.username}**.` + ) + .addFields( + { + name: '๐Ÿท๏ธ Display Name', + value: member?.displayName || targetUser.username, + inline: true + }, + { + name: '๐Ÿ†” ID', + value: targetUser.id, + inline: true + }, + { + name: '๐Ÿค– Bot', + value: targetUser.bot ? 'Yes' : 'No', + inline: true + }, + { + name: '๐Ÿ—“๏ธ Account Created', + value: formatDate(targetUser.createdAt), + inline: true + } + ); + + if (member) { + const roles = member.roles.cache + .filter(role => role.id !== guildRoleId(member.guild)) + .sort((a, b) => b.position - a.position) + .map(role => role.toString()) + .slice(0, 10); + const topRole = member.roles.highest; + embed.addFields( + { + name: '๐Ÿ“… Joined Server', + value: member.joinedAt ? formatDate(member.joinedAt) : 'Unknown', + inline: true + }, + { + name: '๐Ÿ… Top Role', + value: + topRole.id === guildRoleId(member.guild) + ? '*None*' + : topRole.toString(), + inline: true + } + ); + if (roles.length > 0) { + embed.addFields({ + name: '๐ŸŽญ Roles', + value: + roles.join(' ') + + (member.roles.cache.size - 1 > 10 + ? ` **+${member.roles.cache.size - 1 - 10} more**` + : ''), + inline: false + }); + } + } + + embed + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }) + .setTimestamp(); + + return interaction.editReply({ embeds: [embed] }); + } else { + const users = client.guilds.cache.reduce( + (acc, guild) => acc + (guild.memberCount || 0), + 0 + ); + + const embed = new EmbedBuilder() + .setTitle(client.user?.username || 'Master-Bot') + .setThumbnail(client.user?.displayAvatarURL() || null) + .setDescription( + '**Master-Bot** is a versatile Discord bot that brings a full music experience along with moderation, utilities, and fun commands to your server โ€” all controlled through convenient slash commands.' + ) + .setColor('Aqua') + .addFields( + { + name: '๐Ÿค– Servers', + value: client.guilds.cache.size.toLocaleString(), + inline: true + }, + { + name: '๐Ÿ‘ฅ Total Users', + value: users.toLocaleString(), + inline: true + }, + { + name: 'โฑ๏ธ Uptime', + value: client.uptime ? formatUptime(client.uptime) : 'Unknown', + inline: true + }, + { + name: '๐Ÿท๏ธ Tag', + value: client.user?.tag || 'Unknown', + inline: true + }, + { + name: '๐Ÿ†” ID', + value: client.user?.id || 'Unknown', + inline: true + }, + { + name: 'โœจ Activity', + value: + client.user?.presence?.activities + ?.map(activity => activity.name) + .join(', ') || 'None', + inline: true + }, + { + name: '๐Ÿ”— Useful Links', + value: + `[Invite the bot](https://discord.com/oauth2/authorize?client_id=${client.user?.id}&scope=bot&permissions=8) โ€ข ` + + `[Commands](${REPO_URL}#available-commands) โ€ข ` + + `[Support Server](${SUPPORT_DISCORD})`, + inline: false + } + ) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }) + .setTimestamp(); + + return interaction.editReply({ embeds: [embed] }); + } } } + +export const help: CommandHelp = { + name: 'about', + category: 'other', + description: 'Display detailed information about the bot, server, or a user', + usage: '/about [user: @User]', + examples: [ + '/about bot', + '/about server', + '/about user', + '/about user user: @User' + ], + options: [ + { + name: 'bot', + description: 'Display detailed information about Master-Bot.', + required: false + }, + { + name: 'server', + description: 'Display detailed information about this server.', + required: false + }, + { + name: 'user', + description: + 'Display detailed user information (defaults to yourself if omitted).', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/other/activity.ts b/apps/bot/src/commands/other/activity.ts index 9f7a7f3ce..1d687df0e 100644 --- a/apps/bot/src/commands/other/activity.ts +++ b/apps/bot/src/commands/other/activity.ts @@ -1,6 +1,7 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; -import { GuildMember, VoiceChannel } from 'discord.js'; +import { ChannelType, GuildMember, VoiceChannel } from 'discord.js'; @ApplyOptions({ name: 'activity', @@ -34,10 +35,7 @@ export class ActivityCommand extends Command { const channel = interaction.options.getChannel('channel', true); const activity = interaction.options.getString('activity', true); - if ( - channel.type.toString() !== 'GUILD_VOICE' || - channel.type.toString() === 'GUILD_CATEGORY' - ) { + if (channel.type !== ChannelType.GuildVoice) { return interaction.reply({ content: 'You can only invite to voice channels!' }); @@ -72,3 +70,23 @@ export class ActivityCommand extends Command { } } } + +export const help: CommandHelp = { + name: 'activity', + category: 'other', + description: 'Generate an invite link to your voice channel', + usage: '/activity ', + examples: ['/activity channel: value activity: value'], + options: [ + { + name: 'channel', + description: 'Channel to invite to', + required: true + }, + { + name: 'activity', + description: 'Activity to invite to', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/advice.ts b/apps/bot/src/commands/other/advice.ts index 3e2ec6fc4..68b8dc61e 100644 --- a/apps/bot/src/commands/other/advice.ts +++ b/apps/bot/src/commands/other/advice.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -17,14 +18,17 @@ export class AdviceCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); try { const response = await fetch('https://api.adviceslip.com/advice'); - const data = await response.json(); + const data = (await response.json()) as any; const advice = data.slip?.advice; if (!advice) { - return interaction.reply({ content: 'Something went wrong!' }); + return await interaction.editReply({ + content: 'Something went wrong!' + }); } const embed = new EmbedBuilder() @@ -40,9 +44,18 @@ export class AdviceCommand extends Command { text: `Powered by adviceslip.com` }); - return interaction.reply({ embeds: [embed] }); + return await interaction.editReply({ embeds: [embed] }); } catch { - return interaction.reply({ content: 'Something went wrong!' }); + return await interaction.editReply({ content: 'Something went wrong!' }); } } } + +export const help: CommandHelp = { + name: 'advice', + category: 'other', + description: 'Get some advice!', + usage: '/advice', + examples: ['/advice'], + options: [] +}; diff --git a/apps/bot/src/commands/other/avatar.ts b/apps/bot/src/commands/other/avatar.ts index 92b44853c..907742738 100644 --- a/apps/bot/src/commands/other/avatar.ts +++ b/apps/bot/src/commands/other/avatar.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -34,3 +35,18 @@ export class AvatarCommand extends Command { return interaction.reply({ embeds: [embed] }); } } + +export const help: CommandHelp = { + name: 'avatar', + category: 'other', + description: 'Responds with a user', + usage: '/avatar ', + examples: ['/avatar user: value'], + options: [ + { + name: 'user', + description: 'The user to get the avatar of', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/bored.ts b/apps/bot/src/commands/other/bored.ts new file mode 100644 index 000000000..2350c37b1 --- /dev/null +++ b/apps/bot/src/commands/other/bored.ts @@ -0,0 +1,304 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; + +interface ActivityResult { + activity: string; + type: string; + participants: number; + price?: number; + accessibility?: string | number; + link?: string; +} + +const FALLBACK_ACTIVITIES: Record = { + education: [ + 'Learn a new keyboard shortcut in your favorite software', + 'Watch a documentary on deep-sea marine life', + 'Read 3 Wikipedia articles on topics you have never heard of', + 'Learn the basics of a foreign language with an interactive lesson', + 'Explore the history of ancient Roman architecture' + ], + recreational: [ + 'Go on a 20-minute walk without looking at your phone', + 'Play a classic retro game online', + 'Try solving a cryptic crossword puzzle or sudoku', + 'Build a card tower or solve a Rubikโ€™s cube', + 'Start a new casual video game or replay an old favorite' + ], + social: [ + 'Send a message to an old friend you havenโ€™t talked to in a while', + 'Invite a friend to play an online multiplayer game or watch a stream', + 'Host a mini trivia session in voice chat with friends', + 'Compliment 3 different people today', + 'Call a family member to catch up' + ], + diy: [ + 'Organize and clean your computer desktop and file downloads', + 'Rearrange your desk or workspace for better productivity', + 'Create a custom Discord emote or avatar', + 'Fold an origami crane using scrap paper', + 'Repurpose old cardboard into a desk organizer' + ], + charity: [ + 'Donate unused clothes or items to a local shelter', + 'Leave a positive review for a local small business', + 'Pick up 5 pieces of trash in your neighborhood', + 'Offer to help a neighbor or friend with a task', + 'Contribute to an open-source or community wiki project' + ], + cooking: [ + 'Bake homemade cookies or muffins from scratch', + 'Create a custom smoothie with ingredients in your kitchen', + 'Cook a traditional dish from a country you have never visited', + 'Experiment with making your own specialty seasoning blend', + 'Make a warm cup of gourmet hot chocolate or matcha' + ], + relaxation: [ + 'Do a 10-minute guided breathing meditation', + 'Listen to ambient rain sounds or lofi chillhop', + 'Stretch your back, neck, and legs for 10 minutes', + 'Take a relaxing warm shower or bath', + 'Sit by a window and watch the clouds pass' + ], + music: [ + 'Listen to a complete album from an artist youโ€™ve never heard of', + 'Create a personalized playlist for studying or gaming', + 'Learn the chords to your favorite song on an instrument', + 'Explore top charts from a different decade (e.g. 1980s synthpop)', + 'Analyze the lyrics of your all-time favorite song' + ], + busywork: [ + 'Unsubscribe from marketing emails in your inbox', + 'Back up important photos and documents to the cloud', + 'Clean and wipe down your keyboard and monitor screen', + 'Plan your schedule and goals for the upcoming week', + 'Organize your physical wallet or bag' + ] +}; + +function getCategoryColor(type: string): number { + switch (type.toLowerCase()) { + case 'education': + return 0x3498db; // blue + case 'recreational': + return 0x2ecc71; // green + case 'social': + return 0xe91e63; // pink + case 'diy': + return 0xe67e22; // orange + case 'charity': + return 0x9b59b6; // purple + case 'cooking': + return 0xe74c3c; // red + case 'relaxation': + return 0x1abc9c; // teal + case 'music': + return 0xf1c40f; // yellow + default: + return 0x5865f2; // blurple + } +} + +function formatPrice(price?: number): string { + if (price === undefined || price === null || price === 0) return '๐ŸŸข Free'; + if (price <= 0.3) return '๐ŸŸก Inexpensive ($)'; + if (price <= 0.6) return '๐ŸŸ  Moderate ($$)'; + return '๐Ÿ”ด Pricey ($$$)'; +} + +@ApplyOptions({ + name: 'bored', + description: 'Generate a fun, random activity to cure your boredom!', + preconditions: ['isCommandDisabled'] +}) +export class BoredCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('type') + .setDescription('Filter by activity category') + .setRequired(false) + .addChoices( + { name: '๐Ÿ“š Education & Learning', value: 'education' }, + { name: '๐ŸŽฎ Recreational', value: 'recreational' }, + { name: '๐Ÿ‘ฅ Social & Friends', value: 'social' }, + { name: '๐Ÿ› ๏ธ DIY & Crafting', value: 'diy' }, + { name: '๐Ÿ’– Charity & Giving', value: 'charity' }, + { name: '๐Ÿณ Cooking & Baking', value: 'cooking' }, + { name: '๐Ÿง˜ Relaxation & Mindfulness', value: 'relaxation' }, + { name: '๐ŸŽต Music', value: 'music' }, + { name: '๐Ÿ“‹ Productivity & Busywork', value: 'busywork' } + ) + ) + .addIntegerOption(option => + option + .setName('participants') + .setDescription('Number of participants (1-8)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(8) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + await interaction.deferReply(); + const type = interaction.options.getString('type'); + const participants = interaction.options.getInteger('participants'); + + let activityResult: ActivityResult | null = null; + + // 1. Try Bored API v2 (AppBrewery) + try { + const params = new URLSearchParams(); + if (type) params.append('type', type); + if (participants) params.append('participants', participants.toString()); + + const queryStr = params.toString() ? `?${params.toString()}` : ''; + const res = await fetch( + `https://bored-api.appbrewery.com/random${queryStr}`, + { + headers: { 'User-Agent': 'Master-Bot-Discord/1.0' }, + signal: AbortSignal.timeout(3000) + } + ); + + if (res.ok) { + const json = (await res.json()) as ActivityResult; + if (json && json.activity) { + activityResult = json; + } + } + } catch (err) { + // fallback to secondary endpoint or curated list + } + + // 2. Try Secondary Endpoint if primary didn't succeed + if (!activityResult) { + try { + const params = new URLSearchParams(); + if (type) params.append('type', type); + if (participants) + params.append('participants', participants.toString()); + + const queryStr = params.toString() ? `?${params.toString()}` : ''; + const res = await fetch( + `https://bored.api.lewagon.com/api/activity${queryStr}`, + { + headers: { 'User-Agent': 'Master-Bot-Discord/1.0' }, + signal: AbortSignal.timeout(3000) + } + ); + + if (res.ok) { + const json = (await res.json()) as ActivityResult; + if (json && json.activity) { + activityResult = json; + } + } + } catch (err) { + // fallback to curated list + } + } + + // 3. Fallback to Curated In-Memory Activities + if (!activityResult) { + const categoryKey = + type && FALLBACK_ACTIVITIES[type] + ? type + : Object.keys(FALLBACK_ACTIVITIES)[ + Math.floor( + Math.random() * Object.keys(FALLBACK_ACTIVITIES).length + ) + ]; + const list = FALLBACK_ACTIVITIES[categoryKey]; + const chosen = list[Math.floor(Math.random() * list.length)]; + + activityResult = { + activity: chosen, + type: categoryKey, + participants: participants || 1, + price: 0 + }; + } + + const categoryName = + activityResult.type.charAt(0).toUpperCase() + + activityResult.type.slice(1); + const color = getCategoryColor(activityResult.type); + + const embed = new EmbedBuilder() + .setTitle(`๐Ÿ’ก Activity: ${activityResult.activity}`) + .setColor(color) + .setDescription(`Here is a suggested activity to cure your boredom!`) + .addFields( + { + name: '๐ŸŽฏ Category', + value: `**${categoryName}**`, + inline: true + }, + { + name: '๐Ÿ‘ฅ Participants', + value: `**${activityResult.participants || 1}** ${ + (activityResult.participants || 1) === 1 ? 'person' : 'people' + }`, + inline: true + }, + { + name: '๐Ÿ’ฐ Cost', + value: formatPrice(activityResult.price), + inline: true + } + ); + + if (activityResult.link) { + embed.addFields({ + name: '๐Ÿ”— Resource Link', + value: `[Learn More](${activityResult.link})`, + inline: false + }); + } + + embed + .setFooter({ + text: 'Master-Bot Activities โ€ข Never Be Bored!' + }) + .setTimestamp(); + + return await interaction.editReply({ embeds: [embed] }); + } +} + +export const help: CommandHelp = { + name: 'bored', + category: 'other', + description: 'Generate a fun, random activity to cure your boredom!', + usage: '/bored [type: Category] [participants: Number]', + examples: [ + '/bored', + '/bored type: cooking', + '/bored type: social participants: 2' + ], + options: [ + { + name: 'type', + description: 'Activity category (e.g. recreational, cooking, music)', + required: false + }, + { + name: 'participants', + description: 'Number of people participating (1-8)', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/other/chucknorris.ts b/apps/bot/src/commands/other/chucknorris.ts index 763ffe879..ac99e120b 100644 --- a/apps/bot/src/commands/other/chucknorris.ts +++ b/apps/bot/src/commands/other/chucknorris.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -17,15 +18,14 @@ export class ChuckNorrisCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); try { const response = await fetch('https://api.chucknorris.io/jokes/random'); - const data = await response.json(); + const joke = (await response.json()) as any; - const joke = data; - - if (!joke) { - return interaction.reply({ - content: ':x: An error occured, Chuck is investigating this!' + if (!joke || !joke.value) { + return await interaction.editReply({ + content: ':x: An error occurred, Chuck is investigating this!' }); } @@ -34,18 +34,27 @@ export class ChuckNorrisCommand extends Command { .setAuthor({ name: 'Chuck Norris', url: 'https://chucknorris.io', - iconURL: joke.icon_url + iconURL: joke.icon_url || 'https://i.imgur.com/bOVpNAX.png' }) .setDescription(joke.value) .setTimestamp() .setFooter({ text: 'Powered by chucknorris.io' }); - return interaction.reply({ embeds: [embed] }); + return await interaction.editReply({ embeds: [embed] }); } catch { - return interaction.reply({ - content: ':x: An error occured, Chuck is investigating this!' + return await interaction.editReply({ + content: ':x: An error occurred, Chuck is investigating this!' }); } } } + +export const help: CommandHelp = { + name: 'chucknorris', + category: 'other', + description: 'Get a satirical fact about Chuck Norris!', + usage: '/chucknorris', + examples: ['/chucknorris'], + options: [] +}; diff --git a/apps/bot/src/commands/other/connect-four.ts b/apps/bot/src/commands/other/connect-four.ts new file mode 100644 index 000000000..da9f51168 --- /dev/null +++ b/apps/bot/src/commands/other/connect-four.ts @@ -0,0 +1,183 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { Connect4Game } from '../../lib/games/connect-4.js'; +import { GameInvite } from '../../lib/games/inviteEmbed.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import type { User } from 'discord.js'; + +const playersInGame: Map = new Map(); + +@ApplyOptions({ + name: 'connect-four', + description: 'Play a game of Connect Four with another member', + preconditions: ['isCommandDisabled', 'GuildOnly'] +}) +export class ConnectFourCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addUserOption(option => + option + .setName('opponent') + .setDescription('The member you want to challenge (optional)') + .setRequired(false) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const maxPlayers = 2; + const playerMap = new Map(); + const player1 = interaction.user; + const opponent = interaction.options.getUser('opponent'); + + if (opponent?.id === player1.id) { + return interaction.reply({ + content: ':x: You cannot challenge yourself to a game!', + ephemeral: true + }); + } + + if (opponent?.bot) { + return interaction.reply({ + content: ':x: You cannot challenge bots to a game!', + ephemeral: true + }); + } + + if (playersInGame.has(player1.id)) { + return interaction.reply({ + content: ":x: You can't play more than 1 game at a time.", + ephemeral: true + }); + } + + if (opponent && playersInGame.has(opponent.id)) { + return interaction.reply({ + content: `:x: **${opponent.username}** is already in a game!`, + ephemeral: true + }); + } + + playerMap.set(player1.id, player1); + const gameTitle = 'Connect 4'; + const invite = new GameInvite(gameTitle, [player1], interaction); + + await interaction.reply({ + content: opponent + ? `๐Ÿ”ด **${opponent}**, you have been challenged to **Connect Four** by **${player1.username}**!` + : undefined, + embeds: [invite.gameInviteEmbed()], + components: [invite.gameInviteButtons()] + }); + + const inviteCollector = + interaction.channel?.createMessageComponentCollector({ + time: 60 * 1000 + }); + + inviteCollector?.on('collect', async response => { + if (response.customId === `${interaction.id}${player1.id}-No`) { + if (response.user.id !== player1.id) { + playerMap.delete(response.user.id); + } else { + await response.reply({ + content: ':x: You started the invite.', + ephemeral: true + }); + } + } + + if (response.customId === `${interaction.id}${player1.id}-Yes`) { + if (opponent && response.user.id !== opponent.id) { + return response.reply({ + content: `:x: Only ${opponent} can accept this specific challenge!`, + ephemeral: true + }); + } + + if (playersInGame.has(response.user.id)) { + return response.reply({ + content: `:x: You are already playing a game.`, + ephemeral: true + }); + } + + if (!playerMap.has(response.user.id)) { + playerMap.set(response.user.id, response.user); + } + if (playerMap.size === maxPlayers) { + return inviteCollector.stop('start-game'); + } + } + + const accepted: User[] = []; + playerMap.forEach(player => accepted.push(player)); + const updatedInvite = new GameInvite(gameTitle, accepted, interaction); + await response.update({ + embeds: [updatedInvite.gameInviteEmbed()] + }); + + if (response.customId === `${interaction.id}${player1.id}-Start`) { + if (playerMap.has(response.user.id)) { + if (accepted.length > 1) { + playerMap.forEach((player: User) => + playersInGame.set(player.id, player) + ); + return inviteCollector.stop('start-game'); + } + } + } + }); + + inviteCollector?.on('end', async (_collected, reason) => { + await interaction.deleteReply().catch(() => {}); + if (playerMap.size === 1 || reason === 'declined') { + playerMap.forEach(player => playersInGame.delete(player.id)); + } + if (reason === 'time') { + await interaction + .followUp({ + content: `:x: No one responded to your invitation in time.`, + ephemeral: true + }) + .catch(() => {}); + if (playerMap.size > 1) { + playerMap.forEach((player: User) => + playersInGame.set(player.id, player) + ); + return new Connect4Game().connect4(interaction, playerMap); + } + } + if (reason === 'start-game') { + playerMap.forEach((player: User) => + playersInGame.set(player.id, player) + ); + new Connect4Game().connect4(interaction, playerMap); + } + }); + + return; + } +} + +export const help: CommandHelp = { + name: 'connect-four', + category: 'other', + description: 'Play a game of Connect Four with another member', + usage: '/connect-four [opponent: @User]', + examples: ['/connect-four', '/connect-four opponent: @User'], + options: [ + { + name: 'opponent', + description: 'The member you want to challenge (optional)', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/other/dashboard.ts b/apps/bot/src/commands/other/dashboard.ts new file mode 100644 index 000000000..a6e2b5184 --- /dev/null +++ b/apps/bot/src/commands/other/dashboard.ts @@ -0,0 +1,80 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { getApplicationOwnerUser } from '../../lib/music/youtubeOAuth.js'; + +@ApplyOptions({ + name: 'dashboard', + description: 'Get a link to the web dashboard', + preconditions: ['isCommandDisabled'] +}) +export class DashboardCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder // + .setName(this.name) + .setDescription(this.description) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const publicUrl = process.env.NEXTAUTH_URL || ''; + const internalUrl = + process.env.NEXTAUTH_URL_INTERNAL || + `http://localhost:${process.env.PORT || 3000}`; + + const fields: { name: string; value: string; inline?: boolean }[] = []; + + if (publicUrl) { + fields.push({ + name: '๐Ÿ”— Open the Dashboard', + value: `[Click here to open the dashboard](${publicUrl})`, + inline: false + }); + } else { + fields.push({ + name: '๐Ÿ”— Open the Dashboard', + value: `[Click here to open the dashboard](${internalUrl})`, + inline: false + }); + } + + if (internalUrl && publicUrl && internalUrl !== publicUrl) { + const ownerUser = await getApplicationOwnerUser(this.container.client); + if (ownerUser && interaction.user.id === ownerUser.id) { + fields.push({ + name: '๐Ÿ  Local Dashboard (Host)', + value: `[Open local dashboard](${internalUrl})`, + inline: false + }); + } + } + + const embed = new EmbedBuilder() + .setTitle('๐ŸŒ Dashboard') + .setDescription( + 'Manage your server settings, view logs, and more through the web dashboard.' + ) + .setColor('Purple') + .addFields(fields) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }) + .setTimestamp(); + + return interaction.reply({ embeds: [embed] }); + } +} + +export const help: CommandHelp = { + name: 'dashboard', + category: 'other', + description: 'Get a link to the web dashboard', + usage: '/dashboard', + examples: ['/dashboard'], + options: [] +}; diff --git a/apps/bot/src/commands/other/fortune.ts b/apps/bot/src/commands/other/fortune.ts index 7504ed7de..e1ee5c2ef 100644 --- a/apps/bot/src/commands/other/fortune.ts +++ b/apps/bot/src/commands/other/fortune.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -17,14 +18,15 @@ export class FortuneCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); try { const response = await fetch('http://yerkee.com/api/fortune'); - const data = await response.json(); + const data = (await response.json()) as any; const tip = data.fortune; if (!tip) { - return interaction.reply({ + return await interaction.editReply({ content: 'Something went wrong!' }); } @@ -41,11 +43,20 @@ export class FortuneCommand extends Command { .setFooter({ text: 'Powered by yerkee.com' }); - return interaction.reply({ embeds: [embed] }); + return await interaction.editReply({ embeds: [embed] }); } catch { - return interaction.reply({ + return await interaction.editReply({ content: 'Something went wrong!' }); } } } + +export const help: CommandHelp = { + name: 'fortune', + category: 'other', + description: 'Replies with a fortune cookie tip!', + usage: '/fortune', + examples: ['/fortune'], + options: [] +}; diff --git a/apps/bot/src/commands/other/game-search.ts b/apps/bot/src/commands/other/game-search.ts index 8357ef3b9..7053bf51a 100644 --- a/apps/bot/src/commands/other/game-search.ts +++ b/apps/bot/src/commands/other/game-search.ts @@ -1,15 +1,15 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; import { PaginatedMessage } from '@sapphire/discord.js-utilities'; -import { env } from '../../env'; import axios from 'axios'; @ApplyOptions({ name: 'game-search', - description: 'Search for video game information', + description: 'Search for video game information using IGDB', preconditions: ['isCommandDisabled'] }) -export class ChuckNorrisCommand extends Command { +export class GameSearchCommand extends Command { public override registerApplicationCommands(registry: Command.Registry) { registry.registerChatInputCommand(builder => builder @@ -27,208 +27,155 @@ export class ChuckNorrisCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - if (!env.RAWG_API) { - return interaction.reply({ - content: 'This command is disabled because the RAWG API key is not set.' - }); - } + const clientId = process.env.TWITCH_CLIENT_ID; + const clientSecret = process.env.TWITCH_CLIENT_SECRET; - const title = interaction.options.getString('game', true); - const filteredTitle = this.filterTitle(title); - - const game = await this.getGameDetails(filteredTitle); - - if (!game) { + if (!clientId || !clientSecret) { return interaction.reply({ - content: 'No game found with that name' + content: + 'This command requires TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET to be configured for IGDB access.' }); } - const PaginatedEmbed = new PaginatedMessage(); - - const firstPageTuple: string[] = []; // releaseDate, esrbRating, userRating - - if (game.tba) { - firstPageTuple.push('TBA'); - } else if (!game.released) { - firstPageTuple.push('None Listed'); - } else { - firstPageTuple.push(game.released); - } - - if (!game.esrb_rating) { - firstPageTuple.push('None Listed'); - } else { - firstPageTuple.push(game.esrb_rating.name); - } - - if (!game.rating) { - firstPageTuple.push('None Listed'); - } else { - firstPageTuple.push(game.rating + '/5'); - } - - PaginatedEmbed.addPageEmbed(embed => - embed - .setTitle(`Game Info: ${game.name}`) - .setDescription( - '>>> ' + - '**Game Description**\n' + - game.description_raw.slice(0, 2000) + - '...' - ) - .setColor('Grey') - .setThumbnail(game.background_image) - .addFields( - { name: 'Released', value: '> ' + firstPageTuple[0], inline: true }, - { - name: 'ESRB Rating', - value: '> ' + firstPageTuple[1], - inline: true - }, - { name: 'Score', value: '> ' + firstPageTuple[2], inline: true } - ) - .setTimestamp() - ); + const title = interaction.options.getString('game', true); + await interaction.deferReply(); + + try { + const tokenRes = await axios.post( + `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials` + ); + const accessToken = tokenRes.data.access_token; + + const igdbRes = await axios.post( + 'https://api.igdb.com/v4/games', + `search "${title.replace(/"/g, '')}"; fields name, summary, cover.url, first_release_date, total_rating, genres.name, platforms.name, involved_companies.company.name, involved_companies.developer, involved_companies.publisher; limit 1;`, + { + headers: { + 'Client-ID': clientId, + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'text/plain' + } + } + ); - const developerArray: string[] = []; - if (game.developers.length) { - for (let i = 0; i < game.developers.length; ++i) { - developerArray.push(game.developers[i].name); + const game = igdbRes.data?.[0]; + if (!game) { + return interaction.editReply({ + content: `No game found matching "${title}"` + }); } - } else { - developerArray.push('None Listed'); - } - const publisherArray: string[] = []; - if (game.publishers.length) { - for (let i = 0; i < game.publishers.length; ++i) { - publisherArray.push(game.publishers[i].name); - } - } else { - publisherArray.push('None Listed'); - } + const releaseDate = game.first_release_date + ? `` + : 'None Listed'; + const score = game.total_rating + ? `${Math.round(game.total_rating)}/100` + : 'None Listed'; + + const coverUrl = game.cover?.url + ? `https:${game.cover.url.replace('/t_thumb/', '/t_cover_big/')}` + : undefined; + + const genres = + game.genres?.map((g: any) => g.name).join(', ') || 'None Listed'; + const platforms = + game.platforms?.map((p: any) => p.name).join(', ') || 'None Listed'; + + const developers = + game.involved_companies + ?.filter((c: any) => c.developer) + .map((c: any) => c.company?.name) + .filter(Boolean) + .join(', ') || 'None Listed'; + + const publishers = + game.involved_companies + ?.filter((c: any) => c.publisher) + .map((c: any) => c.company?.name) + .filter(Boolean) + .join(', ') || 'None Listed'; + + const PaginatedEmbed = new PaginatedMessage(); + + PaginatedEmbed.addPageEmbed(embed => { + embed + .setTitle(`Game Info: ${game.name}`) + .setDescription( + game.summary + ? `>>> **Game Overview**\n${game.summary.slice(0, 2000)}` + : 'No summary available.' + ) + .setColor('#9146FF'); + + if (coverUrl) embed.setThumbnail(coverUrl); + + embed + .addFields( + { name: 'Release Date', value: `> ${releaseDate}`, inline: true }, + { + name: 'Platforms', + value: `> ${platforms.slice(0, 1024)}`, + inline: true + }, + { name: 'IGDB Rating', value: `> ${score}`, inline: true } + ) + .setTimestamp(); + + return embed; + }); - const platformArray: string[] = []; - if (game.platforms.length) { - for (let i = 0; i < game.platforms.length; ++i) { - platformArray.push(game.platforms[i].platform.name); - } - } else { - platformArray.push('None Listed'); - } + PaginatedEmbed.addPageEmbed(embed => { + embed.setTitle(`Game Details: ${game.name}`).setColor('#9146FF'); + + if (coverUrl) embed.setThumbnail(coverUrl); + + embed + .addFields( + { + name: 'Developer(s)', + value: `> ${developers.slice(0, 1024)}`, + inline: true + }, + { + name: 'Publisher(s)', + value: `> ${publishers.slice(0, 1024)}`, + inline: true + }, + { + name: 'Genre(s)', + value: `> ${genres.slice(0, 1024)}`, + inline: true + } + ) + .setTimestamp(); + + return embed; + }); - const genreArray: string[] = []; - if (game.genres.length) { - for (let i = 0; i < game.genres.length; ++i) { - genreArray.push(game.genres[i].name); + if (PaginatedEmbed.actions.size > 0) { + PaginatedEmbed.actions.delete('@sapphire/paginated-messages.goToPage'); } - } else { - genreArray.push('None Listed'); - } - const retailerArray: string[] = []; - if (game.stores.length) { - for (let i = 0; i < game.stores.length; ++i) { - retailerArray.push( - `[${game.stores[i].store.name}](${game.stores[i].url})` - ); - } - } else { - retailerArray.push('None Listed'); + return PaginatedEmbed.run(interaction); + } catch (error: any) { + return interaction.editReply({ + content: 'An error occurred while fetching game details from IGDB.' + }); } - - PaginatedEmbed.addPageEmbed(embed => - embed - .setTitle(`Game Info: ${game.name}`) - .setColor('Grey') - .setThumbnail(game.background_image_additional ?? game.background_image) - // Row 1 - .addFields( - { - name: developerArray.length == 1 ? 'Developer' : 'Developers', - value: '> ' + developerArray.toString().replace(/,/g, ', '), - inline: true - }, - { - name: publisherArray.length == 1 ? 'Publisher' : 'Publishers', - value: '> ' + publisherArray.toString().replace(/,/g, ', '), - inline: true - }, - { - name: platformArray.length == 1 ? 'Platform' : 'Platforms', - value: '> ' + platformArray.toString().replace(/,/g, ', '), - inline: true - } - ) - // Row 2 - .addFields( - { - name: genreArray.length == 1 ? 'Genre' : 'Genres', - value: '> ' + genreArray.toString().replace(/,/g, ', '), - inline: true - }, - { - name: retailerArray.length == 1 ? 'Retailer' : 'Retailers', - value: - '> ' + - retailerArray.toString().replace(/,/g, ', ').replace(/`/g, '') - } - ) - .setTimestamp() - ); - if (PaginatedEmbed.actions.size > 0) - PaginatedEmbed.actions.delete('@sapphire/paginated-messages.goToPage'); - return PaginatedEmbed.run(interaction); - } - - private filterTitle(title: string) { - return title.replace(/ /g, '-').replace(/' /g, '').toLowerCase(); - } - - private getGameDetails(query: string): Promise { - return new Promise(async function (resolve, reject) { - const url = `https://api.rawg.io/api/games/${encodeURIComponent( - query - )}?key=${env.RAWG_API}`; - try { - const response = await axios.get(url); - if (response.status === 429) { - reject(':x: Rate Limit exceeded. Please try again in a few minutes.'); - } - if (response.status === 503) { - reject( - ':x: The service is currently unavailable. Please try again later.' - ); - } - if (response.status === 404) { - reject(`:x: Error: ${query} was not found`); - } - if (response.status !== 200) { - reject( - ':x: There was a problem getting game from the API, make sure you entered a valid game tittle' - ); - } - - let body = response.data; - if (body.redirect) { - const redirect = await axios.get( - `https://api.rawg.io/api/games/${body.slug}?key=${env.RAWG_API}` - ); - body = redirect.data; - } - // 'id' is the only value that must be present to all valid queries - if (!body.id) { - reject( - ':x: There was a problem getting data from the API, make sure you entered a valid game title' - ); - } - resolve(body); - } catch (e) { - reject( - 'There was a problem getting data from the API, make sure you entered a valid game title' - ); - } - }); } } + +export const help: CommandHelp = { + name: 'game-search', + category: 'other', + description: 'Search for video game information using IGDB', + usage: '/game-search ', + examples: ['/game-search game: value'], + options: [ + { + name: 'game', + description: 'The game you want to look up?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/games.ts b/apps/bot/src/commands/other/games.ts index 1803684fd..5f6e9e2e6 100644 --- a/apps/bot/src/commands/other/games.ts +++ b/apps/bot/src/commands/other/games.ts @@ -1,6 +1,7 @@ -import { TicTacToeGame } from '../../lib/games/tic-tac-toe'; -import { Connect4Game } from '../../lib/games/connect-4'; -import { GameInvite } from '../../lib/games/inviteEmbed'; +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { TicTacToeGame } from '../../lib/games/tic-tac-toe.js'; +import { Connect4Game } from '../../lib/games/connect-4.js'; +import { GameInvite } from '../../lib/games/inviteEmbed.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import type { User } from 'discord.js'; @@ -146,3 +147,12 @@ export class GamesCommand extends Command { ); } } + +export const help: CommandHelp = { + name: 'games', + category: 'other', + description: 'Play games like Connect 4 and Tic Tac Toe with another person', + usage: '/games', + examples: ['/games'], + options: [] +}; diff --git a/apps/bot/src/commands/other/help.ts b/apps/bot/src/commands/other/help.ts index 7e7330d02..2f20a6044 100644 --- a/apps/bot/src/commands/other/help.ts +++ b/apps/bot/src/commands/other/help.ts @@ -1,18 +1,36 @@ -import { - PaginatedMessage, - PaginatedFieldMessageEmbed -} from '@sapphire/discord.js-utilities'; +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { HelpRegistry } from '../../lib/structures/HelpRegistry.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions, container } from '@sapphire/framework'; import { - ApplicationCommandOption, + ActionRowBuilder, AutocompleteInteraction, - EmbedBuilder + ComponentType, + EmbedBuilder, + StringSelectMenuBuilder, + StringSelectMenuOptionBuilder } from 'discord.js'; +const CATEGORY_EMOJIS: Record = { + music: '๐ŸŽต', + gifs: '๐Ÿ–ผ๏ธ', + twitch: '๐ŸŽฎ', + moderation: '๐Ÿ”จ', + other: 'โš™๏ธ' +}; + +const CATEGORY_NAMES: Record = { + music: 'Music & Audio', + gifs: 'Reaction GIFs', + twitch: 'Twitch Live Alerts', + moderation: 'Moderation & Server Management', + other: 'Utilities & General' +}; + @ApplyOptions({ name: 'help', - description: 'Get the Command List or add a command-name to get more info.', + description: + 'Explore the command list or view detailed info for a specific command.', preconditions: ['isCommandDisabled'] }) export class HelpCommand extends Command { @@ -26,136 +44,240 @@ export class HelpCommand extends Command { .addStringOption(option => option .setName('command-name') - .setDescription('Which command would you like to know about?') + .setDescription( + 'Specify a command name to view detailed options and usage.' + ) + .setAutocomplete(true) .setRequired(false) ) ); } - public override autocompleteRun(interaction: AutocompleteInteraction) { - const commands = interaction.client.application?.commands.cache; + public override async autocompleteRun(interaction: AutocompleteInteraction) { const focusedOption = interaction.options.getFocused(true); - const result = commands - ?.sorted((a, b) => a.name.localeCompare(b.name)) - .filter(choice => choice.name.startsWith(focusedOption.value.toString())) - .map(choice => ({ name: choice.name, value: choice.name })) - .slice(0, 10); - interaction; - return interaction.respond(result!); + const enabledCommands = HelpRegistry.getEnabledCommands(); + const result = enabledCommands + .map(cmd => ({ + name: `/${cmd.name} - ${cmd.description.slice(0, 50)}`, + value: cmd.name + })) + .filter(cmd => + cmd.value + .toLowerCase() + .startsWith(focusedOption.value.toString().toLowerCase()) + ) + .slice(0, 25); + + return interaction.respond(result); } + public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { const { client } = container; - const query = interaction.options.getString('command-name')?.toLowerCase(); - const array: CommandInfo[] = []; - - const app = client.application; - app?.commands.cache.each(command => { - array.push({ - name: command.name, - options: command.options, - details: command.description - }); - }); - // Sort the array by name - const sortedList = array.sort((a, b) => { - let fa = a.name.toLowerCase(), - fb = b.name.toLowerCase(); + // 1. Detailed Command Lookup Mode + if (query) { + const { help: targetHelp, disabled } = HelpRegistry.getCommand(query); - if (fa < fb) { - return -1; + if (!targetHelp) { + return await interaction.reply({ + content: `:x: Could not find command **/${query}**. Use \`/help\` to browse available commands.`, + ephemeral: true + }); } - if (fa > fb) { - return 1; + + if (disabled) { + return await interaction.reply({ + content: `:warning: Command **/${query}** is currently disabled while system upgrades are underway.`, + ephemeral: true + }); } - return 0; - }); - if (!query) { - let characters = 0; - let page = 0; - let message: string[] = []; - const PaginatedEmbed = new PaginatedMessage(); - sortedList.forEach((command, index) => { - characters += command.details.length + command.details.length; - message.push(`> **/${command.name}** - ${command.details}\n`); - - if (characters > 1500 || index == sortedList.length - 1) { - page++; - characters = 0; - PaginatedEmbed.addPageEmbed( - new EmbedBuilder() - .setTitle(`Command List - Page ${page}`) - .setThumbnail(app?.iconURL()!) - .setColor('Purple') - .setAuthor({ - name: interaction.user.username + ' - Help Command', - iconURL: interaction.user.displayAvatarURL() - }) - .setDescription(message.toString().replaceAll(',> **/', '> **/')) - ); - message = []; - } + const category = targetHelp.category.toLowerCase(); + const categoryName = + CATEGORY_NAMES[category] || + category.charAt(0).toUpperCase() + category.slice(1); + const categoryEmoji = CATEGORY_EMOJIS[category] || 'โš™๏ธ'; + + const detailEmbed = new EmbedBuilder() + .setTitle(`${categoryEmoji} Command: /${targetHelp.name}`) + .setColor(0x5865f2) + .setThumbnail(client.user?.displayAvatarURL() || null) + .setDescription(`> ${targetHelp.description}`) + .addFields( + { + name: '๐Ÿ“‚ Category', + value: `${categoryEmoji} ${categoryName}`, + inline: true + }, + { + name: '๐Ÿ’ป Usage', + value: `\`${targetHelp.usage || `/${targetHelp.name}`}\``, + inline: true + } + ) + .setFooter({ + text: 'Master-Bot Command Reference', + iconURL: client.user?.displayAvatarURL() + }) + .setTimestamp(); + + if (targetHelp.options && targetHelp.options.length > 0) { + const optionsFormatted = targetHelp.options + .map(opt => { + const req = opt.required ? '`[Required]`' : '`[Optional]`'; + return `โ€ข **${opt.name}** ${req}\n ${opt.description}`; + }) + .join('\n\n'); + + detailEmbed.addFields({ + name: 'โš™๏ธ Parameters & Options', + value: optionsFormatted + }); + } + + if (targetHelp.examples && targetHelp.examples.length > 0) { + detailEmbed.addFields({ + name: '๐Ÿ’ก Examples', + value: targetHelp.examples.map(ex => `\`${ex}\``).join('\n') + }); + } + + return await interaction.reply({ embeds: [detailEmbed] }); + } + + // 2. Full Overview & Dynamic Category Browsing Mode + const categoriesMap = HelpRegistry.getCategoriesMap(); + const enabledCommands = HelpRegistry.getEnabledCommands(); + const totalCommands = enabledCommands.length; + + const mainEmbed = new EmbedBuilder() + .setTitle('๐Ÿค– Master-Bot Command Center') + .setColor(0x5865f2) + .setThumbnail(client.user?.displayAvatarURL() || null) + .setDescription( + `Welcome to **Master-Bot**! Use the select menu below to explore commands by category or type \`/help [command-name]\` for specific usage details.\n\n` + + `**๐Ÿ“Š Quick Stats:**\n` + + `โ€ข Active Commands: **${totalCommands}**\n` + + `โ€ข Active Categories: **${categoriesMap.size}**\n` + + `โ€ข Gateway Latency: **${client.ws.ping}ms**` + ) + .setFooter({ + text: 'Select a category below to view commands โ€ข Master-Bot', + iconURL: client.user?.displayAvatarURL() + }) + .setTimestamp(); + + categoriesMap.forEach((cmds, cat) => { + const emoji = CATEGORY_EMOJIS[cat] || 'โš™๏ธ'; + const label = + CATEGORY_NAMES[cat] || cat.charAt(0).toUpperCase() + cat.slice(1); + mainEmbed.addFields({ + name: `${emoji} ${label} (${cmds.length})`, + value: cmds.map(c => `\`/${c.name}\``).join(' '), + inline: false }); + }); - return PaginatedEmbed.run(interaction); - } else { - const commandMap = new Map(); - sortedList.reduce( - (obj, command) => commandMap.set(command.name, command), - {} + const selectMenu = new StringSelectMenuBuilder() + .setCustomId('help_category_select') + .setPlaceholder('๐Ÿ“‚ Browse commands by category...') + .addOptions( + new StringSelectMenuOptionBuilder() + .setLabel('All Categories Overview') + .setValue('overview') + .setDescription('Return to the main help overview') + .setEmoji('๐Ÿ ') ); - if (commandMap.has(query)) { - const command: CommandInfo = commandMap.get(query); - const optionsList: any[] = []; - command.options.forEach(option => { - optionsList.push({ - name: option.name, - description: option.description - }); + + categoriesMap.forEach((cmds, cat) => { + const emoji = CATEGORY_EMOJIS[cat] || 'โš™๏ธ'; + const label = + CATEGORY_NAMES[cat] || cat.charAt(0).toUpperCase() + cat.slice(1); + selectMenu.addOptions( + new StringSelectMenuOptionBuilder() + .setLabel(label) + .setValue(cat) + .setDescription(`View all ${cmds.length} commands in ${label}`) + .setEmoji(emoji) + ); + }); + + const row = new ActionRowBuilder().addComponents( + selectMenu + ); + + const response = await interaction.reply({ + embeds: [mainEmbed], + components: [row], + fetchReply: true + }); + + const collector = response.createMessageComponentCollector({ + componentType: ComponentType.StringSelect, + time: 60000 + }); + + collector.on('collect', async i => { + if (i.user.id !== interaction.user.id) { + await i.reply({ + content: 'โŒ Only the command initiator can use this menu.', + ephemeral: true }); - const DetailedPagination = new PaginatedFieldMessageEmbed(); + return; + } - const commandDetails = new EmbedBuilder() - .setAuthor({ - name: interaction.user.username + ' - Help Command', - iconURL: interaction.user.displayAvatarURL() - }) - .setThumbnail(app?.iconURL()!) - .setTitle( - `${ - command.name.charAt(0).toUpperCase() + - command.name.slice(1).toLowerCase() - } - Details` - ) - .setColor('Purple') - .setDescription(`**Description**\n> ${command.details}`); - - if (!command.options.length) - return await interaction.reply({ embeds: [commandDetails] }); - - DetailedPagination.setTemplate(commandDetails) - .setTitleField('Options') - .setItems(command.options) - .formatItems( - (option: any) => `**${option.name}**\n> ${option.description}` - ) - .setItemsPerPage(5) - .make(); - - return DetailedPagination.run(interaction); - } else - return await interaction.reply( - `:x: Command: **${query}** was not found` - ); - } - interface CommandInfo { - name: string; - options: ApplicationCommandOption[]; - details: string; - } + const selectedCategory = i.values[0]; + + if (selectedCategory === 'overview') { + await i.update({ embeds: [mainEmbed] }); + return; + } + + const cmds = categoriesMap.get(selectedCategory) || []; + const emoji = CATEGORY_EMOJIS[selectedCategory] || 'โš™๏ธ'; + const label = + CATEGORY_NAMES[selectedCategory] || + selectedCategory.charAt(0).toUpperCase() + selectedCategory.slice(1); + + const categoryEmbed = new EmbedBuilder() + .setTitle(`${emoji} ${label} Commands (${cmds.length})`) + .setColor(0x5865f2) + .setThumbnail(client.user?.displayAvatarURL() || null) + .setDescription( + cmds.map(c => `โ€ข **/${c.name}**\n > ${c.description}`).join('\n\n') + ) + .setFooter({ + text: `Category: ${label} โ€ข Type /help [command] for options`, + iconURL: client.user?.displayAvatarURL() + }) + .setTimestamp(); + + await i.update({ embeds: [categoryEmbed] }); + }); + + collector.on('end', () => { + interaction.editReply({ components: [] }).catch(() => {}); + }); + + return; } } + +export const help: CommandHelp = { + name: 'help', + category: 'other', + description: + 'Explore the command list or view detailed info for a specific command.', + usage: '/help [command-name]', + examples: ['/help', '/help command-name: ping'], + options: [ + { + name: 'command-name', + description: 'Specify a command name to view detailed options and usage.', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/other/insult.ts b/apps/bot/src/commands/other/insult.ts index 9de463329..c13487025 100644 --- a/apps/bot/src/commands/other/insult.ts +++ b/apps/bot/src/commands/other/insult.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -20,14 +21,17 @@ export class InsultCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); try { const response = await fetch( 'https://evilinsult.com/generate_insult.php?lang=en&type=json' ); - const data = await response.json(); + const data = (await response.json()) as any; if (!data.insult) - return interaction.reply({ content: 'Something went wrong!' }); + return await interaction.editReply({ + content: 'Something went wrong!' + }); const embed = new EmbedBuilder() .setColor('Red') @@ -42,11 +46,20 @@ export class InsultCommand extends Command { text: 'Powered by evilinsult.com' }); - return interaction.reply({ embeds: [embed] }); + return await interaction.editReply({ embeds: [embed] }); } catch { - return interaction.reply({ + return await interaction.editReply({ content: 'Something went wrong!' }); } } } + +export const help: CommandHelp = { + name: 'insult', + category: 'other', + description: 'Replies with a mean insult', + usage: '/insult', + examples: ['/insult'], + options: [] +}; diff --git a/apps/bot/src/commands/other/kanye.ts b/apps/bot/src/commands/other/kanye.ts index 08c1c7f06..d75915aba 100644 --- a/apps/bot/src/commands/other/kanye.ts +++ b/apps/bot/src/commands/other/kanye.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -11,12 +12,15 @@ export class KanyeCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); try { const response = await fetch('https://api.kanye.rest/?format=json'); - const data = await response.json(); + const data = (await response.json()) as any; if (!data.quote) - return interaction.reply({ content: 'Something went wrong!' }); + return await interaction.editReply({ + content: 'Something went wrong!' + }); const embed = new EmbedBuilder() .setColor('Orange') @@ -31,9 +35,9 @@ export class KanyeCommand extends Command { text: 'Powered by kanye.rest' }); - return interaction.reply({ embeds: [embed] }); + return await interaction.editReply({ embeds: [embed] }); } catch { - return interaction.reply({ + return await interaction.editReply({ content: 'Something went wrong!' }); } @@ -48,3 +52,12 @@ export class KanyeCommand extends Command { }); } } + +export const help: CommandHelp = { + name: 'kanye', + category: 'other', + description: 'Replies with a random Kanye quote', + usage: '/kanye', + examples: ['/kanye'], + options: [] +}; diff --git a/apps/bot/src/commands/other/motivation.ts b/apps/bot/src/commands/other/motivation.ts index b126b9a60..2856c1b74 100644 --- a/apps/bot/src/commands/other/motivation.ts +++ b/apps/bot/src/commands/other/motivation.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -19,12 +20,15 @@ export class MotivationCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); try { const response = await fetch('https://type.fit/api/quotes'); - const data = await response.json(); + const data = (await response.json()) as any[]; - if (!data) - return await interaction.reply({ content: 'Something went wrong!' }); + if (!Array.isArray(data) || !data.length) + return await interaction.editReply({ + content: 'Something went wrong!' + }); const randomQuote = data[Math.floor(Math.random() * data.length)]; @@ -35,17 +39,28 @@ export class MotivationCommand extends Command { url: 'https://type.fit', iconURL: 'https://i.imgur.com/Cnr6cQb.png' }) - .setDescription(`*"${randomQuote.text}*"\n\n-${randomQuote.author}`) + .setDescription( + `*"${randomQuote.text}"*\n\n-${randomQuote.author || 'Anonymous'}` + ) .setTimestamp() .setFooter({ text: 'Powered by type.fit' }); - return await interaction.reply({ embeds: [embed] }); + return await interaction.editReply({ embeds: [embed] }); } catch { - return await interaction.reply({ + return await interaction.editReply({ content: 'Something went wrong!' }); } } } + +export const help: CommandHelp = { + name: 'motivation', + category: 'other', + description: 'Replies with a motivational quote!', + usage: '/motivation', + examples: ['/motivation'], + options: [] +}; diff --git a/apps/bot/src/commands/other/ping.ts b/apps/bot/src/commands/other/ping.ts index a79967c11..c460e0e03 100644 --- a/apps/bot/src/commands/other/ping.ts +++ b/apps/bot/src/commands/other/ping.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command } from '@sapphire/framework'; @@ -19,3 +20,12 @@ export class PingCommand extends Command { return interaction.reply({ content: 'Pong!' }); } } + +export const help: CommandHelp = { + name: 'ping', + category: 'other', + description: 'Replies with pong!', + usage: '/ping', + examples: ['/ping'], + options: [] +}; diff --git a/apps/bot/src/commands/other/poll.ts b/apps/bot/src/commands/other/poll.ts new file mode 100644 index 000000000..95d4089e8 --- /dev/null +++ b/apps/bot/src/commands/other/poll.ts @@ -0,0 +1,398 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ComponentType, + EmbedBuilder, + Message +} from 'discord.js'; + +const NUMBER_EMOJIS = [ + '1๏ธโƒฃ', + '2๏ธโƒฃ', + '3๏ธโƒฃ', + '4๏ธโƒฃ', + '5๏ธโƒฃ', + '6๏ธโƒฃ', + '7๏ธโƒฃ', + '8๏ธโƒฃ', + '9๏ธโƒฃ', + '๐Ÿ”Ÿ' +]; + +function createProgressBar(percent: number, length: number = 10): string { + const filled = Math.max( + 0, + Math.min(length, Math.round((percent / 100) * length)) + ); + const empty = length - filled; + return 'โ–ˆ'.repeat(filled) + 'โ–‘'.repeat(empty); +} + +function buildPollEmbed( + question: string, + options: string[], + userVotes: Map>, + creatorUsername: string, + creatorAvatar: string, + endTimeUnix: number | null, + isClosed: boolean = false +): EmbedBuilder { + const totalVoters = userVotes.size; + let totalVoteCount = 0; + + // Tally counts + const optionCounts = new Array(options.length).fill(0); + for (const votes of userVotes.values()) { + for (const optIdx of votes) { + if (optIdx >= 0 && optIdx < options.length) { + optionCounts[optIdx]++; + totalVoteCount++; + } + } + } + + const maxVotes = Math.max(...optionCounts, 0); + const winningIndices = optionCounts + .map((count, idx) => (count === maxVotes && count > 0 ? idx : -1)) + .filter(idx => idx !== -1); + + let description = ''; + for (let i = 0; i < options.length; i++) { + const count = optionCounts[i]; + const percent = + totalVoteCount > 0 ? Math.round((count / totalVoteCount) * 100) : 0; + const bar = createProgressBar(percent, 10); + const isWinner = isClosed && winningIndices.includes(i); + const crown = isWinner ? ' ๐Ÿ‘‘' : ''; + + description += `${NUMBER_EMOJIS[i]} **${options[i]}**${crown}\n\`${bar}\` **${count}** votes (${percent}%)\n\n`; + } + + const embed = new EmbedBuilder() + .setTitle(`๐Ÿ“Š ${question}`) + .setColor(isClosed ? 0x95a5a6 : 0x5865f2) + .setDescription(description.trim()) + .addFields({ + name: '๐Ÿ“ˆ Poll Statistics', + value: `๐Ÿ‘ฅ **${totalVoters}** ${totalVoters === 1 ? 'voter' : 'voters'} โ€ข ๐Ÿ—ณ๏ธ **${totalVoteCount}** total ${ + totalVoteCount === 1 ? 'vote' : 'votes' + }`, + inline: true + }); + + if (endTimeUnix) { + embed.addFields({ + name: isClosed ? 'โฑ๏ธ Status' : 'โณ Ending', + value: isClosed + ? '๐Ÿ”’ **Poll Closed**' + : ` ()`, + inline: true + }); + } + + if (isClosed) { + if (winningIndices.length === 0) { + embed.addFields({ + name: '๐Ÿ† Result', + value: 'No votes were cast in this poll.', + inline: false + }); + } else if (winningIndices.length === 1) { + embed.addFields({ + name: '๐Ÿ† Winner', + value: `๐ŸŽ‰ **${options[winningIndices[0]]}** won with **${optionCounts[winningIndices[0]]}** votes!`, + inline: false + }); + } else { + const winners = winningIndices + .map(idx => `**${options[idx]}**`) + .join(', '); + embed.addFields({ + name: '๐Ÿ† Tied Winners', + value: `๐Ÿค Tie between: ${winners} (${maxVotes} votes each)`, + inline: false + }); + } + } + + embed + .setFooter({ + text: `Poll by ${creatorUsername} โ€ข Click buttons below to vote`, + iconURL: creatorAvatar + }) + .setTimestamp(); + + return embed; +} + +function buildButtonRows( + options: string[], + disabled: boolean = false +): ActionRowBuilder[] { + const rows: ActionRowBuilder[] = []; + let currentRow = new ActionRowBuilder(); + + for (let i = 0; i < options.length; i++) { + if (i > 0 && i % 5 === 0) { + rows.push(currentRow); + currentRow = new ActionRowBuilder(); + } + + const truncatedLabel = + options[i].length > 60 ? options[i].slice(0, 57) + '...' : options[i]; + + currentRow.addComponents( + new ButtonBuilder() + .setCustomId(`poll_opt_${i}`) + .setLabel(`${NUMBER_EMOJIS[i]} ${truncatedLabel}`) + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled) + ); + } + + if (currentRow.components.length > 0) { + rows.push(currentRow); + } + + return rows; +} + +@ApplyOptions({ + name: 'poll', + description: 'Create an interactive multi-choice poll with button voting', + preconditions: ['GuildOnly', 'isCommandDisabled'] +}) +export class PollCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('question') + .setDescription('The question or title for the poll') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('options') + .setDescription('Comma-separated list of choices (2 to 10 choices)') + .setRequired(true) + ) + .addIntegerOption(option => + option + .setName('duration') + .setDescription('Poll duration in minutes (optional, 1-1440)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(1440) + ) + .addBooleanOption(option => + option + .setName('allow-multiple') + .setDescription( + 'Allow voters to select multiple options (default: False)' + ) + .setRequired(false) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + await interaction.deferReply(); + + const question = interaction.options.getString('question', true).trim(); + const rawOptions = interaction.options.getString('options', true); + const duration = interaction.options.getInteger('duration'); + const allowMultiple = + interaction.options.getBoolean('allow-multiple') ?? false; + + const options = rawOptions + .split(',') + .map(opt => opt.trim()) + .filter(opt => opt.length > 0); + + if (options.length < 2) { + return await interaction.editReply({ + content: + ':x: You must provide at least **2 choices** separated by commas (e.g. `Yes, No, Maybe`).' + }); + } + + if (options.length > 10) { + return await interaction.editReply({ + content: ':x: A poll cannot have more than **10 choices**.' + }); + } + + const userVotes = new Map>(); + const endTimeUnix = duration + ? Math.floor((Date.now() + duration * 60 * 1000) / 1000) + : null; + + const embed = buildPollEmbed( + question, + options, + userVotes, + interaction.user.username, + interaction.user.displayAvatarURL(), + endTimeUnix, + false + ); + + const rows = buildButtonRows(options, false); + + await interaction.editReply({ + embeds: [embed], + components: rows + }); + + const message = await interaction.fetchReply().catch(() => null); + if (!message || !(message instanceof Message)) return; + + const collectorDuration = duration + ? duration * 60 * 1000 + : 24 * 60 * 60 * 1000; // default to 24h max button listener + const collector = message.createMessageComponentCollector({ + componentType: ComponentType.Button, + time: collectorDuration + }); + + collector.on('collect', async btnInteraction => { + const customId = btnInteraction.customId; + if (!customId.startsWith('poll_opt_')) return; + + const choiceIndex = parseInt(customId.replace('poll_opt_', ''), 10); + if ( + isNaN(choiceIndex) || + choiceIndex < 0 || + choiceIndex >= options.length + ) + return; + + const voterId = btnInteraction.user.id; + let userChoices = userVotes.get(voterId); + + if (!userChoices) { + userChoices = new Set(); + userVotes.set(voterId, userChoices); + } + + let responseMsg = ''; + + if (allowMultiple) { + if (userChoices.has(choiceIndex)) { + userChoices.delete(choiceIndex); + responseMsg = `๐Ÿ—‘๏ธ Removed your vote for **${options[choiceIndex]}**.`; + if (userChoices.size === 0) { + userVotes.delete(voterId); + } + } else { + userChoices.add(choiceIndex); + responseMsg = `โœ… Voted for **${options[choiceIndex]}**!`; + } + } else { + if (userChoices.has(choiceIndex)) { + userChoices.clear(); + userVotes.delete(voterId); + responseMsg = `๐Ÿ—‘๏ธ Removed your vote for **${options[choiceIndex]}**.`; + } else { + userChoices.clear(); + userChoices.add(choiceIndex); + responseMsg = `โœ… Voted for **${options[choiceIndex]}**!`; + } + } + + // Acknowledge voter immediately + await btnInteraction.reply({ + content: responseMsg, + ephemeral: true + }); + + // Update live poll embed + const updatedEmbed = buildPollEmbed( + question, + options, + userVotes, + interaction.user.username, + interaction.user.displayAvatarURL(), + endTimeUnix, + false + ); + + await interaction + .editReply({ + embeds: [updatedEmbed], + components: rows + }) + .catch(() => {}); + }); + + collector.on('end', async () => { + const finalEmbed = buildPollEmbed( + question, + options, + userVotes, + interaction.user.username, + interaction.user.displayAvatarURL(), + endTimeUnix, + true + ); + + const disabledRows = buildButtonRows(options, true); + + await interaction + .editReply({ + embeds: [finalEmbed], + components: disabledRows + }) + .catch(() => {}); + }); + + return; + } +} + +export const help: CommandHelp = { + name: 'poll', + category: 'other', + description: 'Create an interactive multi-choice poll with button voting', + usage: + '/poll question: options: [duration: Minutes] [allow-multiple: True/False]', + examples: [ + '/poll question: What game should we play? options: Valorant, Minecraft, Apex, Overwatch', + '/poll question: Lunch time? options: Pizza, Burgers, Sushi duration: 30', + '/poll question: Should we add this feature? options: Yes, No, Needs changes' + ], + options: [ + { + name: 'question', + description: 'The poll question or topic', + required: true + }, + { + name: 'options', + description: 'Comma-separated choices (2 to 10 choices)', + required: true + }, + { + name: 'duration', + description: 'Poll duration in minutes (optional, 1-1440)', + required: false + }, + { + name: 'allow-multiple', + description: 'Allow members to select multiple choices', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/other/random.ts b/apps/bot/src/commands/other/random.ts index d9e8ad734..dd00c476e 100644 --- a/apps/bot/src/commands/other/random.ts +++ b/apps/bot/src/commands/other/random.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -43,3 +44,23 @@ export class RandomCommand extends Command { return await interaction.reply({ embeds: [rngEmbed] }); } } + +export const help: CommandHelp = { + name: 'random', + category: 'other', + description: 'Generate a random number between two inputs!', + usage: '/random ', + examples: ['/random min: value max: value'], + options: [ + { + name: 'min', + description: 'What is the minimum number?', + required: true + }, + { + name: 'max', + description: 'What is the maximum number?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/reddit.ts b/apps/bot/src/commands/other/reddit.ts index 7de5a2341..6c5a31555 100644 --- a/apps/bot/src/commands/other/reddit.ts +++ b/apps/bot/src/commands/other/reddit.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { @@ -7,7 +8,6 @@ import { } from 'discord.js'; import { PaginatedMessage } from '@sapphire/discord.js-utilities'; import axios from 'axios'; -import Logger from '../../lib/logger'; @ApplyOptions({ name: 'reddit', @@ -70,7 +70,9 @@ export class RedditCommand extends Command { ) { await interaction.deferReply(); const channel = interaction.channel; - if (!channel) return await interaction.reply('Something went wrong :('); // type guard + if (!channel) { + return await interaction.editReply('Something went wrong :('); + } const subreddit = interaction.options.getString('subreddit', true); const sort = interaction.options.getString('sort', true); @@ -80,12 +82,11 @@ export class RedditCommand extends Command { .setPlaceholder('Please select an option') .addOptions(optionsArray); - const menu = await channel.send({ + const menu = await interaction.editReply({ content: `:loud_sound: Do you want to get the ${sort} posts from past hour/week/month/year or all?`, components: [ { type: ComponentType.ActionRow, - components: [row] } ] @@ -93,11 +94,11 @@ export class RedditCommand extends Command { const collector = menu.createMessageComponentCollector({ componentType: ComponentType.StringSelect, - time: 30000 // 30 sec + time: 30000 }); collector.on('end', () => { - if (menu) menu.delete().catch(Logger.error); + if (menu) menu.delete().catch(() => {}); }); collector.on('collect', async i => { @@ -110,15 +111,15 @@ export class RedditCommand extends Command { } else { collector.stop(); const timeFilter = i.values[0]; - this.fetchFromReddit(interaction, subreddit, sort, timeFilter); + await this.fetchFromReddit(interaction, subreddit, sort, timeFilter); return; } }); + + return menu; } else { - this.fetchFromReddit(interaction, subreddit, sort); - return; + return await this.fetchFromReddit(interaction, subreddit, sort); } - return; } private async fetchFromReddit( @@ -130,18 +131,27 @@ export class RedditCommand extends Command { try { var data = await this.getData(subreddit, sort, timeFilter); } catch (error: any) { - return interaction.followUp(error); + return interaction.editReply(error); } - // interaction.followUp('Fetching data from reddit'); + const isNsfwChannel = + interaction.channel && + 'nsfw' in interaction.channel && + Boolean((interaction.channel as any).nsfw); const paginatedEmbed = new PaginatedMessage(); - for (let i = 1; i <= data.children.length; i++) { + let addedPages = 0; + + for (let i = 0; i < data.children.length; i++) { let color: ColorResolvable = 'Orange'; - let redditPost = data.children[i - 1]; + let redditPost = data.children[i]; + + if (redditPost.data.over_18 && !isNsfwChannel) { + continue; // Skip NSFW posts in SFW channels + } if (redditPost.data.title.length > 255) { - redditPost.data.title = redditPost.data.title.substring(0, 252) + '...'; // max title length is 256 + redditPost.data.title = redditPost.data.title.substring(0, 252) + '...'; } if (redditPost.data.selftext.length > 1024) { @@ -150,7 +160,7 @@ export class RedditCommand extends Command { `[Read More...](https://www.reddit.com${redditPost.data.permalink})`; } - if (redditPost.data.over_18) color = 'Red'; // red - nsfw + if (redditPost.data.over_18) color = 'Red'; paginatedEmbed.addPageEmbed(embed => embed @@ -160,10 +170,18 @@ export class RedditCommand extends Command { .setDescription( `${ redditPost.data.over_18 ? '' : redditPost.data.selftext + '\n\n' - }Upvotes: ${redditPost.data.score} :thumbsup: ` + }Upvotes: ${redditPost.data.score} :thumbsup:` ) .setAuthor({ name: redditPost.data.author }) ); + addedPages++; + } + + if (addedPages === 0) { + return interaction.editReply({ + content: + 'No SFW posts found for this subreddit in an age-restricted channel filter.' + }); } return paginatedEmbed.run(interaction); @@ -213,3 +231,24 @@ const optionsArray = [ value: 'all' } ]; + +export const help: CommandHelp = { + name: 'reddit', + category: 'other', + description: 'Get posts from reddit by specifying a subreddit', + usage: '/reddit ', + examples: ['/reddit subreddit: value sort: value'], + options: [ + { + name: 'subreddit', + description: 'Subreddit name', + required: true + }, + { + name: 'sort', + description: + 'What posts do you want to see? Select from best/hot/top/new/controversial/rising', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/reminder.ts b/apps/bot/src/commands/other/reminder.ts new file mode 100644 index 000000000..72e5d6201 --- /dev/null +++ b/apps/bot/src/commands/other/reminder.ts @@ -0,0 +1,350 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { dataService } from '../../dataService.js'; +import { formatReminderText } from '../../lib/reminders/ReminderManager.js'; +import Logger from '../../lib/logger.js'; + +function parseDurationMs(input: string): number | null { + const regex = + /(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hrs?|hours?|d|days?|w|weeks?)/gi; + let totalMs = 0; + let match: RegExpExecArray | null; + let matchedAny = false; + + while ((match = regex.exec(input)) !== null) { + matchedAny = true; + const val = parseInt(match[1], 10); + const unit = match[2].toLowerCase(); + + if (unit.startsWith('s')) totalMs += val * 1000; + else if (unit.startsWith('m')) totalMs += val * 60 * 1000; + else if (unit.startsWith('h')) totalMs += val * 60 * 60 * 1000; + else if (unit.startsWith('d')) totalMs += val * 24 * 60 * 60 * 1000; + else if (unit.startsWith('w')) totalMs += val * 7 * 24 * 60 * 60 * 1000; + } + + if (!matchedAny) { + const pureNum = parseInt(input, 10); + if (!isNaN(pureNum) && pureNum > 0) { + totalMs = pureNum * 60 * 1000; // default to minutes if pure number + } else { + return null; + } + } + + return totalMs > 0 ? totalMs : null; +} + +function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`); + return parts.join(' '); +} + +@ApplyOptions({ + name: 'reminder', + description: 'Create and manage your reminders', + preconditions: ['isCommandDisabled'] +}) +export class ReminderCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addSubcommand(subcommand => + subcommand + .setName('set') + .setDescription('Schedule a new reminder') + .addStringOption(option => + option + .setName('time') + .setDescription('When to remind you (e.g. 10m, 1h, 2d, 30s)') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('event') + .setDescription('What you want to be reminded about') + .setRequired(true) + ) + .addStringOption(option => + option + .setName('description') + .setDescription('Optional extra notes or details') + .setRequired(false) + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('View all of your upcoming scheduled reminders') + ) + .addSubcommand(subcommand => + subcommand + .setName('delete') + .setDescription('Delete an existing reminder by event name') + .addStringOption(option => + option + .setName('event') + .setDescription('The event name of the reminder to delete') + .setRequired(true) + ) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + await interaction.deferReply(); + const subcommand = interaction.options.getSubcommand(true); + const userId = interaction.user.id; + + switch (subcommand) { + case 'set': { + const timeInput = interaction.options.getString('time', true); + const event = interaction.options.getString('event', true); + const description = + interaction.options.getString('description') || null; + + const durationMs = parseDurationMs(timeInput); + if (!durationMs || durationMs < 5000) { + return interaction.editReply({ + content: + ':x: Please provide a valid future time duration (e.g. `10m`, `1h30m`, `2d`). Minimum duration is 5 seconds.' + }); + } + + if (durationMs > 30 * 24 * 60 * 60 * 1000) { + return interaction.editReply({ + content: + ':x: Reminders cannot be set further than 30 days in advance.' + }); + } + + const targetDate = new Date(Date.now() + durationMs); + + try { + await dataService.reminder.create({ + userId, + event, + description, + dateTime: targetDate.toISOString(), + repeat: null, + timeOffset: 0 + }); + } catch (err) { + Logger.error('Failed to save reminder to DB: ', err); + } + + const formattedEvent = formatReminderText(event, { + userId, + user: interaction.user, + event, + dateTime: targetDate.toISOString() + }); + + const formattedNotes = description + ? formatReminderText(description, { + userId, + user: interaction.user, + event, + dateTime: targetDate.toISOString() + }) + : null; + + const embed = new EmbedBuilder() + .setTitle('โฐ Reminder Scheduled') + .setColor(0x5865f2) + .setDescription( + `I'll remind you about **${formattedEvent}** in **${formatDuration(durationMs)}** ().` + ) + .addFields( + { name: '๐Ÿ“ Event', value: formattedEvent, inline: true }, + { + name: 'โฑ๏ธ Remind At', + value: ``, + inline: true + } + ) + .setFooter({ + text: `Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }) + .setTimestamp(); + + if (formattedNotes) { + embed.addFields({ + name: '๐Ÿ“„ Notes', + value: formattedNotes, + inline: false + }); + } + + await interaction.editReply({ embeds: [embed] }); + + // Schedule notification timeout + setTimeout(async () => { + try { + const reminderEmbed = new EmbedBuilder() + .setTitle('๐Ÿ”” Reminder Notification') + .setColor(0xfee75c) + .setDescription( + `Hey ${interaction.user}, here is your scheduled reminder for **${event}**!` + ) + .addFields( + { name: '๐Ÿ“ Event', value: event, inline: true }, + { + name: 'โฐ Scheduled For', + value: ``, + inline: true + } + ) + .setFooter({ + text: 'Master-Bot Reminder System', + iconURL: interaction.client.user?.displayAvatarURL() + }) + .setTimestamp(); + + if (description) { + reminderEmbed.addFields({ + name: '๐Ÿ“„ Notes', + value: description, + inline: false + }); + } + + // Attempt to send DM; if DMs closed, send to original channel + await interaction.user + .send({ embeds: [reminderEmbed] }) + .catch(async () => { + if (interaction.channel && 'send' in interaction.channel) { + await (interaction.channel as any) + .send({ + content: `๐Ÿ”” ${interaction.user}`, + embeds: [reminderEmbed] + }) + .catch(() => {}); + } + }); + + // Clean up from database + await dataService.reminder.delete({ userId, event }) + .catch(() => {}); + } catch (notifyErr) { + Logger.error('Reminder notification delivery error: ', notifyErr); + } + }, durationMs); + + return; + } + + case 'list': { + try { + const result = await dataService.reminder.getByUserId({ userId }); + const reminders = result.reminders || []; + + if (reminders.length === 0) { + return interaction.editReply({ + content: '๐Ÿ“ญ You do not have any active scheduled reminders.' + }); + } + + const embed = new EmbedBuilder() + .setTitle(`โฐ Your Reminders (${reminders.length})`) + .setColor(0x5865f2) + .setDescription( + reminders + .map((r, i) => { + const date = new Date(r.dateTime); + const unix = Math.floor(date.getTime() / 1000); + const desc = r.description ? `\n > *${r.description}*` : ''; + return `**${i + 1}. ${r.event}** โ€” ()${desc}`; + }) + .join('\n\n') + ) + .setFooter({ + text: 'Use /reminder delete [event] to cancel a reminder', + iconURL: interaction.user.displayAvatarURL() + }) + .setTimestamp(); + + return interaction.editReply({ embeds: [embed] }); + } catch (err) { + Logger.error('Failed to query reminders: ', err); + return interaction.editReply({ + content: ':x: An error occurred while retrieving your reminders.' + }); + } + } + + case 'delete': { + const event = interaction.options.getString('event', true); + try { + const del = await dataService.reminder.delete({ userId, event }); + if (del.reminder?.count === 0) { + return interaction.editReply({ + content: `:warning: No active reminder matching **${event}** was found.` + }); + } + + return interaction.editReply({ + content: `:white_check_mark: Successfully deleted reminder **${event}**.` + }); + } catch (err) { + Logger.error('Failed to delete reminder: ', err); + return interaction.editReply({ + content: ':x: An error occurred while deleting your reminder.' + }); + } + } + } + + return; + } +} + +export const help: CommandHelp = { + name: 'reminder', + category: 'other', + description: 'Create and manage your reminders', + usage: '/reminder ', + examples: [ + '/reminder set time: 10m event: Take out pizza', + '/reminder set time: 2h event: Team Sync description: Bring notes', + '/reminder list', + '/reminder delete event: Take out pizza' + ], + options: [ + { + name: 'set', + description: + 'Schedule a new reminder with time, event title, and optional notes.', + required: false + }, + { + name: 'list', + description: 'View all of your upcoming scheduled reminders.', + required: false + }, + { + name: 'delete', + description: 'Delete an active scheduled reminder by event name.', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/other/rockpaperscissors.ts b/apps/bot/src/commands/other/rockpaperscissors.ts index 91259dcd4..5e7783e60 100644 --- a/apps/bot/src/commands/other/rockpaperscissors.ts +++ b/apps/bot/src/commands/other/rockpaperscissors.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { Colors, EmbedBuilder } from 'discord.js'; @@ -33,9 +34,7 @@ export class RockPaperScissorsCommand extends Command { interaction: Command.ChatInputCommandInteraction ) { const move = interaction.options.getString('move', true) as - | 'rock' - | 'paper' - | 'scissors'; + 'rock' | 'paper' | 'scissors'; const resultMessage = this.rpsLogic(move); const embed = new EmbedBuilder() @@ -78,3 +77,18 @@ export class RockPaperScissorsCommand extends Command { } } } + +export const help: CommandHelp = { + name: 'rockpaperscissors', + category: 'other', + description: 'Play rock paper scissors with me!', + usage: '/rockpaperscissors ', + examples: ['/rockpaperscissors move: value'], + options: [ + { + name: 'move', + description: 'What is your move?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/set.ts b/apps/bot/src/commands/other/set.ts new file mode 100644 index 000000000..fd6a50e2b --- /dev/null +++ b/apps/bot/src/commands/other/set.ts @@ -0,0 +1,1107 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { MessageChannel } from '../../lib/structures/ExtendedClient.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions, container } from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + EmbedBuilder, + PermissionFlagsBits, + type ChatInputCommandInteraction, + type GuildMember, + type TextChannel +} from 'discord.js'; +import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; +import { notify } from '../../lib/twitch/notifyChannels.js'; +import { dataService } from '../../dataService.js'; +import Logger from '../../lib/logger.js'; + +function checkTwitchEnabled(): boolean { + const enabled = (process.env.TWITCH_ENABLED || '').toLowerCase() !== 'false'; + return ( + enabled && + Boolean(process.env.TWITCH_CLIENT_ID) && + Boolean(process.env.TWITCH_CLIENT_SECRET) + ); +} + +@ApplyOptions({ + name: 'set', + description: 'Configure server settings (Welcome, Twitch, Logging, Volume)', + preconditions: ['GuildOnly', 'isCommandDisabled'] +}) +export class SetCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + const twitchEnabled = checkTwitchEnabled(); + + registry.registerChatInputCommand(builder => { + builder + .setName(this.name) + .setDescription(this.description) + // Welcome Settings + .addSubcommand(sub => + sub + .setName('welcome-channel') + .setDescription('Set the text channel for welcome greetings') + .addChannelOption(opt => + opt + .setName('channel') + .setDescription('Target text channel') + .setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + ) + .addSubcommand(sub => + sub + .setName('welcome-message') + .setDescription( + 'Set custom welcome text ({user}, {username}, {server}, {position})' + ) + .addStringOption(opt => + opt + .setName('message') + .setDescription('Custom message text') + .setRequired(true) + .setMinLength(4) + .setMaxLength(500) + ) + ) + .addSubcommand(sub => + sub + .setName('welcome-toggle') + .setDescription('Enable or disable automatic welcome messages') + .addBooleanOption(opt => + opt + .setName('enabled') + .setDescription('True to enable, False to disable') + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub + .setName('welcome-test') + .setDescription( + 'Send a test welcome message to preview your settings' + ) + ) + // Logging Settings + .addSubcommand(sub => + sub + .setName('log-channel') + .setDescription( + 'Set the text channel for server audit / moderation logs' + ) + .addChannelOption(opt => + opt + .setName('channel') + .setDescription('Target text channel') + .setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + ) + .addSubcommand(sub => + sub + .setName('log-toggle') + .setDescription('Enable or disable server audit / event logging') + .addBooleanOption(opt => + opt + .setName('enabled') + .setDescription('Set logging active or inactive') + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub + .setName('log-disable') + .setDescription('Disable server audit / event logging') + ) + // Ticket System Settings + .addSubcommand(sub => + sub + .setName('ticket-channel') + .setDescription( + 'Set the text channel where the ticket panel will be located' + ) + .addChannelOption(opt => + opt + .setName('channel') + .setDescription('Target text channel') + .setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + ) + .addSubcommand(sub => + sub + .setName('ticket-toggle') + .setDescription('Enable or disable the support ticket system') + .addBooleanOption(opt => + opt + .setName('enabled') + .setDescription('Set ticket system active or inactive') + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub + .setName('ticket-panel') + .setDescription( + 'Post the interactive support ticket panel embed with button' + ) + ) + .addSubcommand(sub => + sub + .setName('ticket-transcript') + .setDescription( + 'Set channel where closed ticket transcript logs are archived' + ) + .addChannelOption(opt => + opt + .setName('channel') + .setDescription('Target transcript channel') + .setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + ) + .addSubcommand(sub => + sub + .setName('ticket-transcript-disable') + .setDescription('Disable automatic ticket transcript archival') + ) + .addSubcommand(sub => + sub + .setName('ticket-role') + .setDescription('Set the ticket manager role for support tickets') + .addRoleOption(opt => + opt + .setName('role') + .setDescription('Role that manages support tickets') + .setRequired(true) + ) + ) + .addSubcommand(sub => + sub + .setName('ticket-role-disable') + .setDescription('Remove/disable the ticket manager role') + ) + // Volume Setting + .addSubcommand(sub => + sub + .setName('default-volume') + .setDescription('Set default music playback volume for this server') + .addIntegerOption(opt => + opt + .setName('volume') + .setDescription('Default volume level (1 - 100)') + .setRequired(true) + .setMinValue(1) + .setMaxValue(100) + ) + ) + // View Setting Overview + .addSubcommand(sub => + sub + .setName('view') + .setDescription('View all current server configuration settings') + ); + + // Conditionally register Twitch subcommands only if Twitch is enabled + if (twitchEnabled) { + builder + .addSubcommand(sub => + sub + .setName('twitch-add') + .setDescription('Add a Twitch streamer live alert to a channel') + .addStringOption(opt => + opt + .setName('streamer') + .setDescription('Twitch streamer login/username') + .setRequired(true) + ) + .addChannelOption(opt => + opt + .setName('channel') + .setDescription('Channel to send live alerts to') + .setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + ) + .addSubcommand(sub => + sub + .setName('twitch-remove') + .setDescription( + 'Remove a Twitch streamer live alert from a channel' + ) + .addStringOption(opt => + opt + .setName('streamer') + .setDescription('Twitch streamer login/username') + .setRequired(true) + ) + .addChannelOption(opt => + opt + .setName('channel') + .setDescription('Channel to remove alert from') + .setRequired(true) + .addChannelTypes(ChannelType.GuildText) + ) + ) + .addSubcommand(sub => + sub + .setName('twitch-list') + .setDescription( + 'View all active Twitch streamer alerts for this server' + ) + ); + } + + return builder; + }); + } + + public override async chatInputRun(interaction: ChatInputCommandInteraction) { + const guildId = interaction.guildId!; + const member = interaction.member as GuildMember; + const { client } = container; + + if (!member.permissions.has(PermissionFlagsBits.ManageGuild)) { + return await interaction.reply({ + content: + ':x: You must have the `Manage Server` permission to configure bot settings.', + ephemeral: true + }); + } + + await interaction.deferReply(); + + const subcommand = interaction.options.getSubcommand(true); + + try { + switch (subcommand) { + // --- WELCOME --- + case 'welcome-channel': { + const channel = interaction.options.getChannel('channel', true); + await dataService.welcome.setChannel({ + guildId, + channelId: channel.id + }); + return await interaction.editReply({ + content: `:white_check_mark: Welcome messages will now be sent in <#${channel.id}>.` + }); + } + + case 'welcome-message': { + const message = interaction.options.getString('message', true); + await dataService.welcome.setMessage({ + guildId, + message + }); + return await interaction.editReply({ + content: `:white_check_mark: Custom welcome message updated!\n\n**Preview:**\n> ${message}` + }); + } + + case 'welcome-toggle': { + const enabled = interaction.options.getBoolean('enabled', true); + await dataService.welcome.toggle({ + guildId, + status: enabled + }); + return await interaction.editReply({ + content: `:white_check_mark: Welcome message system is now **${ + enabled ? 'ENABLED' : 'DISABLED' + }**.` + }); + } + + case 'welcome-test': { + const guildData = await dataService.guild.getGuild({ + id: guildId + }); + const welcomeChannelId = guildData?.guild?.welcomeMessageChannel; + const rawMessage = + guildData?.guild?.welcomeMessage || + '๐Ÿ‘‹ Welcome {user} to **{server}**! You are member #{memberCount}.'; + + if (!welcomeChannelId) { + return await interaction.editReply({ + content: + ':x: No welcome channel configured yet. Use `/set welcome-channel` first.' + }); + } + + const targetChannel = (await interaction.guild?.channels.fetch( + welcomeChannelId + )) as TextChannel; + if (!targetChannel) { + return await interaction.editReply({ + content: ':x: Configured welcome channel could not be found.' + }); + } + + const formatted = rawMessage + .replace(/\{user\}|\{mention\}/g, `<@${interaction.user.id}>`) + .replace(/\{username\}/g, interaction.user.username) + .replace( + /\{server\}|\{guild\}/g, + interaction.guild?.name || 'this server' + ) + .replace( + /\{memberCount\}|\{position\}/g, + String(interaction.guild?.memberCount || 1) + ); + + await targetChannel.send({ content: formatted }); + return await interaction.editReply({ + content: `:white_check_mark: Sent a test welcome message to <#${welcomeChannelId}>!` + }); + } + + // --- TWITCH --- + case 'twitch-add': { + if (!checkTwitchEnabled()) { + return await interaction.editReply({ + content: + ':warning: Twitch features are currently disabled in configuration.' + }); + } + const streamerName = interaction.options.getString('streamer', true); + const channelData = interaction.options.getChannel('channel', true); + + let user: any; + try { + user = await client.twitch.api.getUser({ + login: streamerName, + token: client.twitch.auth.access_token + }); + } catch { + return await interaction.editReply({ + content: `:x: Could not lookup streamer '${streamerName}'. Please check the name.` + }); + } + + if (!user) { + return await interaction.editReply({ + content: `:x: Streamer **${streamerName}** was not found on Twitch.` + }); + } + + const guildDB = await dataService.guild.getGuild({ + id: guildId + }); + if (!guildDB.guild) { + return await interaction.editReply({ + content: ':x: Server data not found.' + }); + } + + const currentNotifyList: string[] = Array.isArray( + guildDB.guild.notifyList + ) + ? guildDB.guild.notifyList + : (JSON.parse(guildDB.guild.notifyList || '[]') as string[]); + + if (currentNotifyList.includes(user.id)) { + return await interaction.editReply({ + content: `:x: **${user.display_name}** is already on your alert list.` + }); + } + + const existingSendTo = + client.twitch.notifyList[user.id]?.sendTo || []; + const updatedSendTo = Array.from( + new Set([...existingSendTo, channelData.id]) + ); + + client.twitch.notifyList[user.id] = { + sendTo: updatedSendTo, + live: false, + logo: user.profile_image_url, + messageSent: false, + messageHandler: {} + }; + + await dataService.twitch.create({ + userId: user.id, + userImage: user.profile_image_url, + channelId: channelData.id, + sendTo: updatedSendTo + }); + + const concatedArray = Array.from( + new Set([...currentNotifyList, user.id]) + ); + await dataService.twitch.createViaTwitchNotification({ + name: interaction.guild?.name || '', + guildId, + notifyList: concatedArray, + ownerId: guildDB.guild.ownerId, + userId: interaction.user.id + }); + + await notify(Object.keys(client.twitch.notifyList)); + return await interaction.editReply({ + content: `:white_check_mark: Stream alerts for **${user.display_name}** will be sent to <#${channelData.id}>.` + }); + } + + case 'twitch-remove': { + if (!checkTwitchEnabled()) { + return await interaction.editReply({ + content: + ':warning: Twitch features are currently disabled in configuration.' + }); + } + const streamerName = interaction.options.getString('streamer', true); + const channelData = interaction.options.getChannel('channel', true); + + let user: any; + try { + user = await client.twitch.api.getUser({ + login: streamerName, + token: client.twitch.auth.access_token + }); + } catch { + return await interaction.editReply({ + content: `:x: Could not lookup streamer '${streamerName}'. Please check the name.` + }); + } + + if (!user) { + return await interaction.editReply({ + content: `:x: Streamer **${streamerName}** was not found on Twitch.` + }); + } + + const guildDB = await dataService.guild.getGuild({ + id: guildId + }); + const removeNotifyList: string[] = Array.isArray( + guildDB.guild?.notifyList + ) + ? (guildDB.guild?.notifyList as string[]) + : (JSON.parse(guildDB.guild?.notifyList || '[]') as string[]); + + if (!guildDB.guild || !removeNotifyList.includes(user.id)) { + return await interaction.editReply({ + content: `:x: **${user.display_name}** is not in this server's alert list.` + }); + } + + const filteredTwitchIds = removeNotifyList.filter( + id => id !== user.id + ); + await dataService.twitch.updateTwitchNotifications({ + guildId, + notifyList: filteredTwitchIds + }); + + const notifyDB = await dataService.twitch.findUserById({ + id: user.id + }); + if (notifyDB?.notification) { + const filteredChannels = notifyDB.notification.channelIds.filter( + id => id !== channelData.id + ); + if (filteredChannels.length === 0) { + await dataService.twitch.delete({ + userId: user.id + }); + delete client.twitch.notifyList[user.id]; + } else { + await dataService.twitch.updateNotification({ + userId: user.id, + channelIds: filteredChannels + }); + if (client.twitch.notifyList[user.id]) { + client.twitch.notifyList[user.id].sendTo = filteredChannels; + } + } + } + + return await interaction.editReply({ + content: `:white_check_mark: Removed **${user.display_name}** alerts from <#${channelData.id}>.` + }); + } + + case 'twitch-list': { + if (!checkTwitchEnabled()) { + return await interaction.editReply({ + content: + ':warning: Twitch features are currently disabled in configuration.' + }); + } + const guildDB = await dataService.guild.getGuild({ + id: guildId + }); + const listNotifyList: string[] = Array.isArray( + guildDB.guild?.notifyList + ) + ? (guildDB.guild?.notifyList as string[]) + : (JSON.parse(guildDB.guild?.notifyList || '[]') as string[]); + + if (!guildDB?.guild || listNotifyList.length === 0) { + return await interaction.editReply({ + content: + ':information_source: No Twitch streamers configured for alerts in this server.' + }); + } + + const users = await client.twitch.api.getUsers({ + ids: listNotifyList, + token: client.twitch.auth.access_token + }); + + const myList: object[] = []; + for (const streamer of users || []) { + const sendTo = client.twitch.notifyList[streamer.id]?.sendTo || []; + for (const chId of sendTo) { + const ch = client.channels.cache.get(chId) as MessageChannel; + if (ch && ch.guild.id === guildId) { + myList.push({ + name: streamer.display_name, + channel: ch.name + }); + } + } + } + + const baseEmbed = new EmbedBuilder().setColor('Purple').setAuthor({ + name: `${interaction.guild?.name} - Twitch Alerts`, + iconURL: interaction.guild?.iconURL() || undefined + }); + + new PaginatedFieldMessageEmbed() + .setTitleField('Streamers') + .setTemplate(baseEmbed) + .setItems(myList) + .formatItems( + (item: any) => `โ€ข **${item.name}** โž” **#${item.channel}**` + ) + .setItemsPerPage(10) + .make() + .run(interaction); + return; + } + + // --- LOGGING --- + case 'log-channel': { + const channel = interaction.options.getChannel('channel', true); + await dataService.guild.setLogChannel({ + guildId, + channelId: channel.id + }); + return await interaction.editReply({ + content: `:white_check_mark: Server audit & moderation logs enabled and routed to <#${channel.id}>.` + }); + } + + case 'log-toggle': { + const enabled = interaction.options.getBoolean('enabled', true); + await dataService.guild.toggleLogChannel({ + guildId, + status: enabled + }); + return await interaction.editReply({ + content: `:white_check_mark: Server audit & moderation logging is now **${ + enabled ? 'ENABLED' : 'DISABLED' + }**.` + }); + } + + case 'log-disable': { + await dataService.guild.setLogChannel({ + guildId, + channelId: null + }); + return await interaction.editReply({ + content: + ':white_check_mark: Server audit & moderation logging has been **DISABLED**.' + }); + } + + // --- TICKETS --- + case 'ticket-channel': { + const channel = interaction.options.getChannel( + 'channel', + true + ) as TextChannel; + await dataService.tickets.setChannel({ + guildId, + channelId: channel.id + }); + + const ticketConfig = await dataService.tickets.getConfig({ + guildId + }); + const template = + ticketConfig.guild?.ticketMessage && + ticketConfig.guild.ticketMessage.trim().length > 0 + ? ticketConfig.guild.ticketMessage + : '๐Ÿ‘‹ Welcome to **{server}** Support!\n\n' + + 'Need assistance, have an inquiry, or want to speak with server staff?\n' + + 'โ€ข Please have any relevant screenshots, error logs, or details ready.\n' + + 'โ€ข A support representative or moderator will assist you shortly.\n\n' + + 'Click the **Open Ticket** button below to create your private support thread.'; + + const formatted = template + .replace( + /\{server\}|\{guild\}/g, + interaction.guild?.name || 'Server' + ) + .replace(/\{user\}|\{mention\}|\{username\}/g, 'you'); + + // Automatically send the ticket panel message to the configured channel + const panelEmbed = new EmbedBuilder() + .setTitle( + `๐ŸŽซ ${interaction.guild?.name || 'Server'} Support Tickets` + ) + .setDescription(formatted) + .setColor(0x5865f2) + .setFooter({ + text: 'Support Ticket System โ€ข Master-Bot', + iconURL: interaction.guild?.iconURL() || undefined + }) + .setTimestamp(); + + const openButton = new ButtonBuilder() + .setCustomId('ticket_create') + .setLabel('Open Ticket') + .setStyle(ButtonStyle.Primary) + .setEmoji('๐ŸŽซ'); + + const row = new ActionRowBuilder().addComponents( + openButton + ); + + await channel + .send({ + embeds: [panelEmbed], + components: [row] + }) + .catch(() => {}); + + return await interaction.editReply({ + content: `:white_check_mark: Support ticket channel set to <#${channel.id}> and the interactive ticket panel has been posted!` + }); + } + + case 'ticket-toggle': { + const enabled = interaction.options.getBoolean('enabled', true); + await dataService.tickets.toggle({ + guildId, + status: enabled + }); + + if (enabled && interaction.guild) { + const ticketConfig = await dataService.tickets.getConfig({ + guildId + }); + const channelId = ticketConfig.guild?.ticketChannel; + + if (channelId) { + const targetChannel = (await interaction.guild.channels + .fetch(channelId) + .catch(() => null)) as TextChannel | null; + + if (targetChannel) { + const template = + ticketConfig.guild?.ticketMessage && + ticketConfig.guild.ticketMessage.trim().length > 0 + ? ticketConfig.guild.ticketMessage + : '๐Ÿ‘‹ Welcome to **{server}** Support!\n\n' + + 'Need assistance, have an inquiry, or want to speak with server staff?\n' + + 'โ€ข Please have any relevant screenshots, error logs, or details ready.\n' + + 'โ€ข A support representative or moderator will assist you shortly.\n\n' + + 'Click the **Open Ticket** button below to create your private support thread.'; + + const formatted = template + .replace(/\{server\}|\{guild\}/g, interaction.guild.name) + .replace(/\{user\}|\{mention\}|\{username\}/g, 'you'); + + const panelEmbed = new EmbedBuilder() + .setTitle(`๐ŸŽซ ${interaction.guild.name} Support Tickets`) + .setDescription(formatted) + .setColor(0x5865f2) + .setFooter({ + text: 'Support Ticket System โ€ข Master-Bot', + iconURL: interaction.guild.iconURL() || undefined + }) + .setTimestamp(); + + const openButton = new ButtonBuilder() + .setCustomId('ticket_create') + .setLabel('Open Ticket') + .setStyle(ButtonStyle.Primary) + .setEmoji('๐ŸŽซ'); + + const row = new ActionRowBuilder().addComponents( + openButton + ); + + await targetChannel + .send({ + embeds: [panelEmbed], + components: [row] + }) + .catch(() => {}); + } + } + } + + return await interaction.editReply({ + content: `:white_check_mark: Support ticket system is now **${ + enabled ? 'ENABLED' : 'DISABLED' + }**${enabled ? ' and the ticket panel has been posted to the ticket channel.' : '.'}` + }); + } + + case 'ticket-panel': { + const ticketConfig = await dataService.tickets.getConfig({ + guildId + }); + const channelId = ticketConfig.guild?.ticketChannel; + + if (!channelId) { + return await interaction.editReply({ + content: + ':x: No ticket channel configured yet. Use `/set ticket-channel` first.' + }); + } + + const targetChannel = (await interaction.guild?.channels.fetch( + channelId + )) as TextChannel; + if (!targetChannel) { + return await interaction.editReply({ + content: ':x: Configured ticket channel could not be found.' + }); + } + + const template = + ticketConfig.guild?.ticketMessage && + ticketConfig.guild.ticketMessage.trim().length > 0 + ? ticketConfig.guild.ticketMessage + : '๐Ÿ‘‹ Welcome to **{server}** Support!\n\n' + + 'Need assistance, have an inquiry, or want to speak with server staff?\n' + + 'โ€ข Please have any relevant screenshots, error logs, or details ready.\n' + + 'โ€ข A support representative or moderator will assist you shortly.\n\n' + + 'Click the **Open Ticket** button below to create your private support thread.'; + + const formatted = template + .replace( + /\{server\}|\{guild\}/g, + interaction.guild?.name || 'Server' + ) + .replace(/\{user\}|\{mention\}|\{username\}/g, 'you'); + + const panelEmbed = new EmbedBuilder() + .setTitle( + `๐ŸŽซ ${interaction.guild?.name || 'Server'} Support Tickets` + ) + .setDescription(formatted) + .setColor(0x5865f2) + .setFooter({ + text: 'Support Ticket System โ€ข Master-Bot', + iconURL: interaction.guild?.iconURL() || undefined + }) + .setTimestamp(); + + const openButton = new ButtonBuilder() + .setCustomId('ticket_create') + .setLabel('Open Ticket') + .setStyle(ButtonStyle.Primary) + .setEmoji('๐ŸŽซ'); + + const row = new ActionRowBuilder().addComponents( + openButton + ); + + await targetChannel.send({ + embeds: [panelEmbed], + components: [row] + }); + + return await interaction.editReply({ + content: `:white_check_mark: Interactive ticket panel has been posted in <#${channelId}>!` + }); + } + + case 'ticket-transcript': { + const channel = interaction.options.getChannel('channel', true); + await dataService.tickets.setTranscriptChannel({ + guildId, + channelId: channel.id + }); + return await interaction.editReply({ + content: `:white_check_mark: Ticket transcripts will now be saved and posted to <#${channel.id}> when tickets are closed.` + }); + } + + case 'ticket-transcript-disable': { + await dataService.tickets.setTranscriptChannel({ + guildId, + channelId: null + }); + return await interaction.editReply({ + content: + ':white_check_mark: Ticket transcript archival has been **DISABLED**.' + }); + } + + case 'ticket-role': { + const role = interaction.options.getRole('role', true); + await dataService.tickets.setRole({ + guildId, + roleId: role.id + }); + return await interaction.editReply({ + content: `:white_check_mark: Ticket manager role set to <@&${role.id}>. Members with this role will be added to newly created support tickets.` + }); + } + + case 'ticket-role-disable': { + await dataService.tickets.setRole({ + guildId, + roleId: null + }); + return await interaction.editReply({ + content: + ':white_check_mark: Ticket manager role has been **DISABLED**.' + }); + } + + // --- VOLUME --- + case 'default-volume': { + const volume = interaction.options.getInteger('volume', true); + await dataService.guild.updateVolume({ + guildId, + volume + }); + return await interaction.editReply({ + content: `:white_check_mark: Default playback volume for this server set to **${volume}%**.` + }); + } + + // --- VIEW --- + case 'view': { + const guildData = await dataService.guild.getGuild({ + id: guildId + }); + const ticketConfig = await dataService.tickets.getConfig({ + guildId + }); + const g = guildData?.guild; + const t = ticketConfig?.guild; + const twitchActive = checkTwitchEnabled(); + + const embed = new EmbedBuilder() + .setTitle(`โš™๏ธ Server Settings - ${interaction.guild?.name}`) + .setColor('Blue') + .addFields( + { + name: '๐Ÿ‘‹ Welcome System', + value: g?.welcomeMessageEnabled + ? '๐ŸŸข **Enabled**' + : '๐Ÿ”ด **Disabled**', + inline: true + }, + { + name: '๐Ÿ“ข Welcome Channel', + value: g?.welcomeMessageChannel + ? `<#${g.welcomeMessageChannel}>` + : '*Not set*', + inline: true + }, + { + name: '๐Ÿ“œ Log Channel', + value: + g?.logChannelEnabled && g?.logChannel + ? `๐ŸŸข <#${g.logChannel}>` + : g?.logChannel + ? `๐Ÿ”ด <#${g.logChannel}> *(Paused)*` + : '*Disabled*', + inline: true + }, + { + name: '๐ŸŽซ Support Tickets', + value: + t?.ticketEnabled && t?.ticketChannel + ? `๐ŸŸข <#${t.ticketChannel}>` + : t?.ticketChannel + ? `๐Ÿ”ด <#${t.ticketChannel}> *(Disabled)*` + : '*Not configured*', + inline: true + }, + { + name: '๐Ÿ“‘ Transcript Channel', + value: t?.ticketTranscriptChannel + ? `๐ŸŸข <#${t.ticketTranscriptChannel}>` + : '*Not set*', + inline: true + }, + { + name: '๐Ÿ›ก๏ธ Ticket Manager Role', + value: t?.ticketRoleId ? `<@&${t.ticketRoleId}>` : '*Not set*', + inline: true + }, + { + name: '๐Ÿ”Š Default Music Volume', + value: `${g?.volume ?? 100}%`, + inline: true + }, + { + name: '๐ŸŸฃ Twitch Alerts', + value: twitchActive + ? `${g?.notifyList?.length || 0} streamer(s) monitored` + : '*Disabled in config*', + inline: true + }, + { + name: '๐Ÿ“ Welcome Template', + value: g?.welcomeMessage + ? `> ${g.welcomeMessage}` + : '> ๐Ÿ‘‹ Welcome {user} to **{server}**! You are member #{memberCount}. *(Default)*', + inline: false + } + ) + .setFooter({ + text: 'Use /set to configure settings' + }) + .setTimestamp(); + + return await interaction.editReply({ embeds: [embed] }); + } + } + return; + } catch (error) { + Logger.error(error); + if (interaction.deferred || interaction.replied) { + return await interaction.editReply({ + content: ':x: An error occurred while processing settings.' + }); + } + return await interaction.reply({ + content: ':x: An error occurred while processing settings.', + ephemeral: true + }); + } + } +} + +export const help: CommandHelp = { + name: 'set', + category: 'other', + description: + 'Configure server settings (Welcome, Twitch, Logging, Tickets, Volume)', + usage: '/set ', + examples: [ + '/set welcome-channel channel: #welcome', + '/set welcome-message message: Welcome {user} to {server}!', + '/set welcome-toggle enabled: True', + '/set twitch-add streamer: shroud channel: #streams', + '/set log-channel channel: #mod-logs', + '/set log-toggle enabled: True', + '/set ticket-channel channel: #support', + '/set ticket-toggle enabled: True', + '/set ticket-panel', + '/set ticket-role role: @SupportTeam', + '/set default-volume volume: 80', + '/set view' + ], + options: [ + { + name: 'welcome-channel', + description: 'Set welcome channel', + required: false + }, + { + name: 'welcome-message', + description: 'Set custom welcome message', + required: false + }, + { + name: 'welcome-toggle', + description: 'Toggle welcome greetings on/off', + required: false + }, + { + name: 'welcome-test', + description: 'Send preview welcome message', + required: false + }, + { + name: 'twitch-add', + description: 'Add streamer alert (if Twitch enabled)', + required: false + }, + { + name: 'twitch-remove', + description: 'Remove streamer alert (if Twitch enabled)', + required: false + }, + { + name: 'twitch-list', + description: 'List monitored streamers (if Twitch enabled)', + required: false + }, + { + name: 'log-channel', + description: 'Set audit/moderation log channel', + required: false + }, + { + name: 'log-disable', + description: 'Disable server event logging', + required: false + }, + { + name: 'ticket-channel', + description: 'Set support ticket panel channel', + required: false + }, + { + name: 'ticket-toggle', + description: 'Toggle support ticket system', + required: false + }, + { + name: 'ticket-panel', + description: 'Post support ticket embed panel', + required: false + }, + { + name: 'ticket-transcript', + description: 'Set ticket transcript archive channel', + required: false + }, + { + name: 'ticket-transcript-disable', + description: 'Disable ticket transcript archiving', + required: false + }, + { + name: 'ticket-role', + description: 'Set ticket manager role', + required: false + }, + { + name: 'ticket-role-disable', + description: 'Disable ticket manager role', + required: false + }, + { + name: 'default-volume', + description: 'Set default playback volume', + required: false + }, + { + name: 'view', + description: 'View current settings overview', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/other/speedrun.ts b/apps/bot/src/commands/other/speedrun.ts index 38edac0bd..c4ec45903 100644 --- a/apps/bot/src/commands/other/speedrun.ts +++ b/apps/bot/src/commands/other/speedrun.ts @@ -1,9 +1,10 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { PaginatedMessage } from '@sapphire/discord.js-utilities'; import { Command, CommandOptions } from '@sapphire/framework'; import axios from 'axios'; import { EmbedBuilder, Colors, ButtonStyle, ComponentType } from 'discord.js'; -import Logger from '../../lib/logger'; +import Logger from '../../lib/logger.js'; @ApplyOptions({ name: 'speedrun', @@ -319,24 +320,44 @@ export class SpeedRunCommand extends Command { ms === undefined ? min.toString() + 'm ' + sec.toString() + 's' : min.toString() + - 'm ' + - sec.toString() + - 's ' + - ms.toString() + - 'ms'; + 'm ' + + sec.toString() + + 's ' + + ms.toString() + + 'ms'; } else { str = ms === undefined ? hr.toString() + 'h ' + min.toString() + 'm ' + sec.toString() + 's' : hr.toString() + - 'h ' + - min.toString() + - 'm ' + - sec.toString() + - 's ' + - ms.toString() + - 'ms'; + 'h ' + + min.toString() + + 'm ' + + sec.toString() + + 's ' + + ms.toString() + + 'ms'; } return str; } } + +export const help: CommandHelp = { + name: 'speedrun', + category: 'other', + description: 'Look for the world record of a game!', + usage: '/speedrun [category]', + examples: ['/speedrun game: value category: value'], + options: [ + { + name: 'game', + description: 'Video Game Title?', + required: true + }, + { + name: 'category', + description: 'speed run Category?', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/other/tic-tac-toe.ts b/apps/bot/src/commands/other/tic-tac-toe.ts new file mode 100644 index 000000000..5a6480107 --- /dev/null +++ b/apps/bot/src/commands/other/tic-tac-toe.ts @@ -0,0 +1,183 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { TicTacToeGame } from '../../lib/games/tic-tac-toe.js'; +import { GameInvite } from '../../lib/games/inviteEmbed.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import type { User } from 'discord.js'; + +const playersInGame: Map = new Map(); + +@ApplyOptions({ + name: 'tic-tac-toe', + description: 'Play a game of Tic-Tac-Toe with another member', + preconditions: ['isCommandDisabled', 'GuildOnly'] +}) +export class TicTacToeCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addUserOption(option => + option + .setName('opponent') + .setDescription('The member you want to challenge (optional)') + .setRequired(false) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const maxPlayers = 2; + const playerMap = new Map(); + const player1 = interaction.user; + const opponent = interaction.options.getUser('opponent'); + + if (opponent?.id === player1.id) { + return interaction.reply({ + content: ':x: You cannot challenge yourself to a game!', + ephemeral: true + }); + } + + if (opponent?.bot) { + return interaction.reply({ + content: ':x: You cannot challenge bots to a game!', + ephemeral: true + }); + } + + if (playersInGame.has(player1.id)) { + return interaction.reply({ + content: ":x: You can't play more than 1 game at a time.", + ephemeral: true + }); + } + + if (opponent && playersInGame.has(opponent.id)) { + return interaction.reply({ + content: `:x: **${opponent.username}** is already in a game!`, + ephemeral: true + }); + } + + playerMap.set(player1.id, player1); + const gameTitle = 'Tic-Tac-Toe'; + const invite = new GameInvite(gameTitle, [player1], interaction); + + await interaction.reply({ + content: opponent + ? `๐ŸŽฎ **${opponent}**, you have been challenged to **Tic-Tac-Toe** by **${player1.username}**!` + : undefined, + embeds: [invite.gameInviteEmbed()], + components: [invite.gameInviteButtons()] + }); + + const inviteCollector = + interaction.channel?.createMessageComponentCollector({ + time: 60 * 1000 + }); + + inviteCollector?.on('collect', async response => { + if (response.customId === `${interaction.id}${player1.id}-No`) { + if (response.user.id !== player1.id) { + playerMap.delete(response.user.id); + } else { + await response.reply({ + content: ':x: You started the invite.', + ephemeral: true + }); + } + } + + if (response.customId === `${interaction.id}${player1.id}-Yes`) { + if (opponent && response.user.id !== opponent.id) { + return response.reply({ + content: `:x: Only ${opponent} can accept this specific challenge!`, + ephemeral: true + }); + } + + if (playersInGame.has(response.user.id)) { + return response.reply({ + content: `:x: You are already playing a game.`, + ephemeral: true + }); + } + + if (!playerMap.has(response.user.id)) { + playerMap.set(response.user.id, response.user); + } + if (playerMap.size === maxPlayers) { + return inviteCollector.stop('start-game'); + } + } + + const accepted: User[] = []; + playerMap.forEach(player => accepted.push(player)); + const updatedInvite = new GameInvite(gameTitle, accepted, interaction); + await response.update({ + embeds: [updatedInvite.gameInviteEmbed()] + }); + + if (response.customId === `${interaction.id}${player1.id}-Start`) { + if (playerMap.has(response.user.id)) { + if (accepted.length > 1) { + playerMap.forEach((player: User) => + playersInGame.set(player.id, player) + ); + return inviteCollector.stop('start-game'); + } + } + } + }); + + inviteCollector?.on('end', async (_collected, reason) => { + await interaction.deleteReply().catch(() => {}); + if (playerMap.size === 1 || reason === 'declined') { + playerMap.forEach(player => playersInGame.delete(player.id)); + } + if (reason === 'time') { + await interaction + .followUp({ + content: `:x: No one responded to your invitation in time.`, + ephemeral: true + }) + .catch(() => {}); + if (playerMap.size > 1) { + playerMap.forEach((player: User) => + playersInGame.set(player.id, player) + ); + return new TicTacToeGame().ticTacToe(interaction, playerMap); + } + } + if (reason === 'start-game') { + playerMap.forEach((player: User) => + playersInGame.set(player.id, player) + ); + new TicTacToeGame().ticTacToe(interaction, playerMap); + } + }); + + return; + } +} + +export const help: CommandHelp = { + name: 'tic-tac-toe', + category: 'other', + description: 'Play a game of Tic-Tac-Toe with another member', + usage: '/tic-tac-toe [opponent: @User]', + examples: ['/tic-tac-toe', '/tic-tac-toe opponent: @User'], + options: [ + { + name: 'opponent', + description: 'The member you want to challenge (optional)', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/other/translate.ts b/apps/bot/src/commands/other/translate.ts index c719f9f22..6f80ae79b 100644 --- a/apps/bot/src/commands/other/translate.ts +++ b/apps/bot/src/commands/other/translate.ts @@ -1,9 +1,11 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import axios from 'axios'; import { EmbedBuilder } from 'discord.js'; import translate from 'google-translate-api-x'; -import Logger from '../../lib/logger'; +import Logger from '../../lib/logger.js'; + @ApplyOptions({ name: 'translate', description: @@ -35,34 +37,57 @@ export class TranslateCommand extends Command { ); } - public override chatInputRun( + public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); const targetLang = interaction.options.getString('target', true); - const text = interaction.options.getString('text', true); - translate(text, { - to: targetLang, - requestFunction: axios - }) - .then(async (response: any) => { - const embed = new EmbedBuilder() - .setColor('DarkRed') - .setTitle('Google Translate') - .setURL('https://translate.google.com/') - .setDescription(response.text) - .setFooter({ - iconURL: 'https://i.imgur.com/ZgFxIwe.png', // Google Translate Icon - text: 'Powered by Google Translate' - }); - return await interaction.reply({ embeds: [embed] }); - }) - .catch(async error => { - Logger.error(error); - return await interaction.reply( - ':x: Something went wrong when trying to translate the text' - ); + try { + const response: any = await translate(text, { + to: targetLang, + requestFunction: axios }); + + const embed = new EmbedBuilder() + .setColor('DarkRed') + .setTitle('Google Translate') + .setURL('https://translate.google.com/') + .setDescription(response.text) + .setFooter({ + iconURL: 'https://i.imgur.com/ZgFxIwe.png', + text: 'Powered by Google Translate' + }); + + return await interaction.editReply({ embeds: [embed] }); + } catch (error) { + Logger.error(error); + return await interaction.editReply( + ':x: Something went wrong when trying to translate the text' + ); + } } } + +export const help: CommandHelp = { + name: 'translate', + category: 'other', + description: + 'Translate from any language to any language using Google Translate', + usage: '/translate ', + examples: ['/translate target: es text: Hello world'], + options: [ + { + name: 'target', + description: + 'What is the target language?(language you want to translate to)', + required: true + }, + { + name: 'text', + description: 'What text do you want to translate?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/trump.ts b/apps/bot/src/commands/other/trump.ts index 7575da125..fbacd4e9f 100644 --- a/apps/bot/src/commands/other/trump.ts +++ b/apps/bot/src/commands/other/trump.ts @@ -1,8 +1,9 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; import axios from 'axios'; -import Logger from '../../lib/logger'; +import Logger from '../../lib/logger.js'; @ApplyOptions({ name: 'trump', description: 'Replies with a random Trump quote', @@ -48,3 +49,12 @@ export class TrumpCommand extends Command { }); } } + +export const help: CommandHelp = { + name: 'trump', + category: 'other', + description: 'Replies with a random Trump quote', + usage: '/trump', + examples: ['/trump'], + options: [] +}; diff --git a/apps/bot/src/commands/other/tv-show-search.ts b/apps/bot/src/commands/other/tv-show-search.ts index 0f79c0e5e..a79899fa8 100644 --- a/apps/bot/src/commands/other/tv-show-search.ts +++ b/apps/bot/src/commands/other/tv-show-search.ts @@ -1,8 +1,9 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { PaginatedMessage } from '@sapphire/discord.js-utilities'; import axios from 'axios'; -import Logger from '../../lib/logger'; +import Logger from '../../lib/logger.js'; @ApplyOptions({ name: 'tv-show-search', @@ -29,12 +30,13 @@ export class TVShowSearchCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); const query = interaction.options.getString('query', true); try { var data = await this.getData(query); } catch (error: any) { - return interaction.reply({ content: error }); + return interaction.editReply({ content: error }); } const PaginatedEmbed = new PaginatedMessage(); @@ -72,17 +74,16 @@ export class TVShowSearchCommand extends Command { { name: 'Average Rating', value: showInfo.rating } ) .setFooter({ - text: `(Page ${i}/${data.length}) Powered by tvmaze.com`, + text: `(Page ${i + 1}/${data.length}) Powered by tvmaze.com`, iconURL: 'https://static.tvmaze.com/images/favico/favicon-32x32.png' }) ); } - await interaction.reply('Show info'); return PaginatedEmbed.run(interaction); } - private getData(query: string): Promise { + private getData(query: string): Promise { return new Promise(async function (resolve, reject) { const url = `http://api.tvmaze.com/search/shows?q=${encodeURI(query)}`; try { @@ -101,10 +102,8 @@ export class TVShowSearchCommand extends Command { ); } const data = response.data; - if (!data.length) { - reject( - 'There was a problem getting data from the API, make sure you entered a valid TV show name' - ); + if (!Array.isArray(data) || !data.length) { + reject(':x: No TV shows found matching your query.'); } resolve(data); } catch (e) { @@ -118,8 +117,8 @@ export class TVShowSearchCommand extends Command { private constructInfoObject(show: any): InfoObject { return { - name: show.name, - url: show.url, + name: show.name || 'Unknown Show', + url: show.url || 'https://www.tvmaze.com', summary: this.filterSummary(show.summary), language: this.checkIfNull(show.language), genres: this.checkGenres(show.genres), @@ -127,14 +126,18 @@ export class TVShowSearchCommand extends Command { premiered: this.checkIfNull(show.premiered), network: this.checkNetwork(show.network), runtime: show.runtime ? show.runtime + ' Minutes' : 'None Listed', - rating: show.ratings ? show.rating.average : 'None Listed', - thumbnail: show.image - ? show.image.original - : 'https://static.tvmaze.com/images/no-img/no-img-portrait-text.png' + rating: show.rating?.average + ? String(show.rating.average) + : 'None Listed', + thumbnail: + show.image?.original || show.image?.medium + ? show.image.original || show.image.medium + : 'https://static.tvmaze.com/images/no-img/no-img-portrait-text.png' }; } - private filterSummary(summary: string) { + private filterSummary(summary: string | null | undefined) { + if (!summary) return 'No description available.'; return summary .replace(/<(\/)?b>/g, '**') .replace(/<(\/)?i>/g, '*') @@ -148,26 +151,27 @@ export class TVShowSearchCommand extends Command { .replace(/'/g, "'"); } - private checkGenres(genres: Genres) { + private checkGenres(genres: any) { if (Array.isArray(genres)) { if (genres.join(' ').trim().length == 0) return 'None Listed'; - return genres.join(' '); - } else if (!genres.length) { + return genres.join(', '); + } else if (!genres) { return 'None Listed'; } - return genres; + return String(genres); } - private checkIfNull(value: string) { + private checkIfNull(value: any) { if (!value) { return 'None Listed'; } - return value; + return String(value); } private checkNetwork(network: any) { if (!network) return 'None Listed'; - return `(**${network.country.code}**) ${network.name}`; + const code = network.country?.code ? `(**${network.country.code}**) ` : ''; + return `${code}${network.name || 'Unknown Network'}`; } } @@ -185,6 +189,17 @@ type InfoObject = { thumbnail: string; }; -type Genres = string | Array; - -type ResponseData = string | Array; +export const help: CommandHelp = { + name: 'tv-show-search', + category: 'other', + description: 'Get TV shows information', + usage: '/tv-show-search ', + examples: ['/tv-show-search query: value'], + options: [ + { + name: 'query', + description: 'What TV show do you want to look up?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/urban.ts b/apps/bot/src/commands/other/urban.ts index ca4f76a77..88831bf53 100644 --- a/apps/bot/src/commands/other/urban.ts +++ b/apps/bot/src/commands/other/urban.ts @@ -1,8 +1,9 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; import axios from 'axios'; -import Logger from '../../lib/logger'; +import Logger from '../../lib/logger.js'; @ApplyOptions({ name: 'urban', @@ -26,34 +27,61 @@ export class UrbanCommand extends Command { ); } - public override chatInputRun( + public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); const query = interaction.options.getString('query', true); - axios - .get(`https://api.urbandictionary.com/v0/define?term=${query}`) - .then(async response => { - const definition: string = response.data.list[0].definition; - const embed = new EmbedBuilder() - .setColor('DarkOrange') - .setAuthor({ - name: 'Urban Dictionary', - url: 'https://urbandictionary.com', - iconURL: 'https://i.imgur.com/vdoosDm.png' - }) - .setDescription(definition) - .setURL(response.data.list[0].permalink) - .setTimestamp() - .setFooter({ - text: 'Powered by UrbanDictionary' - }); - return interaction.reply({ embeds: [embed] }); - }) - .catch(async error => { - Logger.error(error); - return interaction.reply({ - content: 'Failed to deliver definition :sob:' + try { + const response = await axios.get( + `https://api.urbandictionary.com/v0/define?term=${encodeURIComponent(query)}` + ); + const list = response.data?.list; + if (!Array.isArray(list) || list.length === 0) { + return await interaction.editReply({ + content: `:x: No definitions found for "**${query}**".` }); + } + + const item = list[0]; + const definition = + item.definition?.slice(0, 2048) || 'No definition available.'; + const embed = new EmbedBuilder() + .setColor('DarkOrange') + .setAuthor({ + name: 'Urban Dictionary', + url: 'https://urbandictionary.com', + iconURL: 'https://i.imgur.com/vdoosDm.png' + }) + .setTitle(item.word || query) + .setDescription(definition) + .setURL(item.permalink || 'https://urbandictionary.com') + .setTimestamp() + .setFooter({ + text: 'Powered by UrbanDictionary' + }); + + return await interaction.editReply({ embeds: [embed] }); + } catch (error) { + Logger.error(error); + return await interaction.editReply({ + content: ':x: Failed to deliver definition. Please try again later.' }); + } } } + +export const help: CommandHelp = { + name: 'urban', + category: 'other', + description: 'Get definitions from urban dictionary', + usage: '/urban ', + examples: ['/urban query: salty'], + options: [ + { + name: 'query', + description: 'What term do you want to look up?', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/weather.ts b/apps/bot/src/commands/other/weather.ts new file mode 100644 index 000000000..1cb5a6840 --- /dev/null +++ b/apps/bot/src/commands/other/weather.ts @@ -0,0 +1,223 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import Logger from '../../lib/logger.js'; + +function getWeatherColor(condition: string): number { + const lower = condition.toLowerCase(); + if (lower.includes('sunny') || lower.includes('clear')) return 0xf1c40f; // gold + if ( + lower.includes('rain') || + lower.includes('shower') || + lower.includes('drizzle') + ) + return 0x3498db; // blue + if (lower.includes('thunder') || lower.includes('storm')) return 0x9b59b6; // purple + if ( + lower.includes('snow') || + lower.includes('blizzard') || + lower.includes('ice') + ) + return 0xecf0f1; // light white/grey + if ( + lower.includes('cloud') || + lower.includes('overcast') || + lower.includes('mist') || + lower.includes('fog') + ) + return 0x95a5a6; // grey + return 0x5865f2; // blurple default +} + +function getWeatherEmoji(condition: string): string { + const lower = condition.toLowerCase(); + if (lower.includes('sunny') || lower.includes('clear')) return 'โ˜€๏ธ'; + if (lower.includes('partly cloudy')) return 'โ›…'; + if (lower.includes('cloud') || lower.includes('overcast')) return 'โ˜๏ธ'; + if (lower.includes('thunder') || lower.includes('storm')) return 'โ›ˆ๏ธ'; + if ( + lower.includes('snow') || + lower.includes('blizzard') || + lower.includes('ice') + ) + return 'โ„๏ธ'; + if ( + lower.includes('rain') || + lower.includes('shower') || + lower.includes('drizzle') + ) + return '๐ŸŒง๏ธ'; + if (lower.includes('fog') || lower.includes('mist')) return '๐ŸŒซ๏ธ'; + return '๐ŸŒก๏ธ'; +} + +@ApplyOptions({ + name: 'weather', + description: 'Get current weather and 3-day forecast for any location', + preconditions: ['isCommandDisabled'] +}) +export class WeatherCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('location') + .setDescription('City, region, or location name') + .setRequired(true) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + await interaction.deferReply(); + const query = interaction.options.getString('location', true); + + try { + const encoded = encodeURIComponent(query.trim()); + const response = await fetch(`https://wttr.in/${encoded}?format=j1`, { + headers: { + 'User-Agent': 'Master-Bot-Discord/1.0' + } + }); + + if (!response.ok) { + return await interaction.editReply({ + content: `:warning: Could not find weather data for **${query}**. Please check the spelling and try again.` + }); + } + + const data = (await response.json()) as any; + const current = data?.current_condition?.[0]; + const area = data?.nearest_area?.[0]; + + if (!current || !area) { + return await interaction.editReply({ + content: `:warning: No weather reports available for **${query}**.` + }); + } + + const areaName = area.areaName?.[0]?.value || query; + const region = area.region?.[0]?.value || ''; + const country = area.country?.[0]?.value || ''; + const locationHeader = [areaName, region, country] + .filter(Boolean) + .join(', '); + + const conditionDesc = current.weatherDesc?.[0]?.value || 'Unknown'; + const emoji = getWeatherEmoji(conditionDesc); + const color = getWeatherColor(conditionDesc); + + const tempC = current.temp_C; + const tempF = current.temp_F; + const feelsC = current.FeelsLikeC; + const feelsF = current.FeelsLikeF; + const humidity = current.humidity; + const windSpeedMph = current.windspeedMiles; + const windSpeedKmph = current.windspeedKmph; + const windDir = current.winddir16Point; + const uvIndex = current.uvIndex; + const visibility = current.visibility; + + const embed = new EmbedBuilder() + .setTitle(`${emoji} Weather for ${locationHeader}`) + .setColor(color) + .setDescription(`**Current Conditions:** ${conditionDesc}`) + .addFields( + { + name: '๐ŸŒก๏ธ Temperature', + value: `**${tempC}ยฐC** / **${tempF}ยฐF**\n*(Feels like ${feelsC}ยฐC / ${feelsF}ยฐF)*`, + inline: true + }, + { + name: '๐Ÿ’ง Humidity', + value: `**${humidity}%**`, + inline: true + }, + { + name: '๐Ÿ’จ Wind', + value: `**${windSpeedMph} mph** (${windSpeedKmph} km/h)\nDirection: **${windDir}**`, + inline: true + }, + { + name: 'โ˜€๏ธ UV Index', + value: `**${uvIndex}**`, + inline: true + }, + { + name: '๐Ÿ‘๏ธ Visibility', + value: `**${visibility} km**`, + inline: true + } + ); + + // 3-Day Forecast + const forecasts = data.weather || []; + if (forecasts.length > 0) { + const forecastLines = forecasts + .slice(0, 3) + .map((f: any, idx: number) => { + const dateStr = f.date; + const maxC = f.maxtempC; + const maxF = f.maxtempF; + const minC = f.mintempC; + const minF = f.mintempF; + const dayDesc = + f.hourly?.[4]?.weatherDesc?.[0]?.value || + f.hourly?.[0]?.weatherDesc?.[0]?.value || + 'Partly Cloudy'; + const dayEmoji = getWeatherEmoji(dayDesc); + const label = + idx === 0 ? 'Today' : idx === 1 ? 'Tomorrow' : dateStr; + + return `โ€ข **${label}**: ${dayEmoji} ${dayDesc} | High: **${maxC}ยฐC** (${maxF}ยฐF) โ€ข Low: **${minC}ยฐC** (${minF}ยฐF)`; + }); + + embed.addFields({ + name: '๐Ÿ“… 3-Day Forecast', + value: forecastLines.join('\n'), + inline: false + }); + } + + embed + .setFooter({ + text: 'Weather Data provided by wttr.in โ€ข Master-Bot' + }) + .setTimestamp(); + + return await interaction.editReply({ embeds: [embed] }); + } catch (error) { + Logger.error('Weather command error: ', error); + return await interaction.editReply({ + content: ':x: An unexpected error occurred while fetching weather data.' + }); + } + } +} + +export const help: CommandHelp = { + name: 'weather', + category: 'other', + description: 'Get current weather and 3-day forecast for any location', + usage: '/weather ', + examples: [ + '/weather location: Tokyo', + '/weather location: London', + '/weather location: New York' + ], + options: [ + { + name: 'location', + description: 'City, region, or location name', + required: true + } + ] +}; diff --git a/apps/bot/src/commands/other/world-news.ts b/apps/bot/src/commands/other/world-news.ts new file mode 100644 index 000000000..16d0b9969 --- /dev/null +++ b/apps/bot/src/commands/other/world-news.ts @@ -0,0 +1,217 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { getApiServiceKeys } from '../../env.js'; +import Logger from '../../lib/logger.js'; + +interface NewsArticle { + source: { id: string | null; name: string }; + author: string | null; + title: string; + description: string | null; + url: string; + urlToImage: string | null; + publishedAt: string; +} + +@ApplyOptions({ + name: 'world-news', + description: 'Fetch the latest global headlines and breaking news', + preconditions: ['isCommandDisabled'] +}) +export class WorldNewsCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('category') + .setDescription('Topic category to fetch headlines for') + .setRequired(false) + .addChoices( + { name: 'General / Breaking', value: 'general' }, + { name: 'Technology', value: 'technology' }, + { name: 'Business & Finance', value: 'business' }, + { name: 'Science & Space', value: 'science' }, + { name: 'Health & Medicine', value: 'health' }, + { name: 'Entertainment', value: 'entertainment' }, + { name: 'Sports', value: 'sports' } + ) + ) + .addStringOption(option => + option + .setName('query') + .setDescription( + 'Search for specific keywords (e.g. AI, NASA, economy)' + ) + .setRequired(false) + ) + .addStringOption(option => + option + .setName('country') + .setDescription( + 'Country edition for top headlines (defaults to Global/US)' + ) + .setRequired(false) + .addChoices( + { name: 'United States (US)', value: 'us' }, + { name: 'United Kingdom (UK)', value: 'gb' }, + { name: 'Canada (CA)', value: 'ca' }, + { name: 'Australia (AU)', value: 'au' }, + { name: 'Germany (DE)', value: 'de' }, + { name: 'France (FR)', value: 'fr' }, + { name: 'India (IN)', value: 'in' }, + { name: 'Japan (JP)', value: 'jp' } + ) + ) + ); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + const apiKey = getApiServiceKeys().newsApi; + if (!apiKey) { + return interaction.reply({ + content: + ':warning: NewsAPI key is not configured on this bot instance.', + ephemeral: true + }); + } + + await interaction.deferReply(); + + const category = interaction.options.getString('category'); + const query = interaction.options.getString('query'); + const country = + interaction.options.getString('country') || + (category || !query ? 'us' : undefined); + + let apiUrl: string; + if (query && !category) { + apiUrl = `https://newsapi.org/v2/everything?q=${encodeURIComponent(query)}&language=en&sortBy=relevancy&pageSize=5&apiKey=${apiKey}`; + } else { + const params = new URLSearchParams(); + if (country) params.set('country', country); + if (category) params.set('category', category); + if (query) params.set('q', query); + params.set('pageSize', '5'); + params.set('apiKey', apiKey); + apiUrl = `https://newsapi.org/v2/top-headlines?${params.toString()}`; + } + + try { + const response = await fetch(apiUrl); + if (!response.ok) { + const errorText = await response.text().catch(() => ''); + Logger.error( + `NewsAPI request failed [HTTP ${response.status}]: ${errorText}` + ); + return interaction.editReply({ + content: + ':x: Could not retrieve news articles at this time. Please try again later.' + }); + } + + const data = (await response.json()) as { + status: string; + totalResults: number; + articles: NewsArticle[]; + }; + + const articles = + data.articles?.filter(a => a.title && a.title !== '[Removed]') || []; + if (articles.length === 0) { + return interaction.editReply({ + content: `๐Ÿ” No news articles found matching your query${query ? ` for "**${query}**"` : ''}.` + }); + } + + const categoryLabel = category + ? category.charAt(0).toUpperCase() + category.slice(1) + : query + ? `Search: "${query}"` + : 'Top World News'; + + const embed = new EmbedBuilder() + .setTitle(`๐Ÿ“ฐ ${categoryLabel}`) + .setColor(0x5865f2) + .setDescription( + articles + .map((article, idx) => { + const date = new Date(article.publishedAt); + const unix = !isNaN(date.getTime()) + ? Math.floor(date.getTime() / 1000) + : null; + const timeStr = unix ? ` โ€ข ` : ''; + const sourceStr = article.source?.name + ? `*${article.source.name}*` + : ''; + const desc = article.description + ? `\n> ${article.description.length > 140 ? article.description.slice(0, 137) + '...' : article.description}` + : ''; + + return `**${idx + 1}. [${article.title}](<${article.url}>)**\nโ€” ${sourceStr}${timeStr}${desc}`; + }) + .join('\n\n') + ) + .setFooter({ + text: `Powered by NewsAPI.org โ€ข Requested by ${interaction.user.username}`, + iconURL: interaction.user.displayAvatarURL() + }) + .setTimestamp(); + + const topImage = articles.find( + a => a.urlToImage && a.urlToImage.startsWith('http') + )?.urlToImage; + if (topImage) { + embed.setThumbnail(topImage); + } + + return interaction.editReply({ embeds: [embed] }); + } catch (err) { + Logger.error('World News command error: ', err); + return interaction.editReply({ + content: + ':x: An unexpected error occurred while querying the news service.' + }); + } + } +} + +export const help: CommandHelp = { + name: 'world-news', + category: 'other', + description: 'Fetch the latest global headlines and breaking news', + usage: '/world-news [category: Topic] [query: Keyword] [country: Country]', + examples: [ + '/world-news', + '/world-news category: Technology', + '/world-news query: artificial intelligence', + '/world-news category: Science country: United States (US)' + ], + options: [ + { + name: 'category', + description: + 'News topic category (General, Technology, Business, Science, Health, Sports, Entertainment)', + required: false + }, + { + name: 'query', + description: 'Search for specific keywords or topics', + required: false + }, + { + name: 'country', + description: + 'Country edition for top headlines (US, GB, CA, AU, DE, FR, IN, JP)', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/twitch/add-streamer.ts b/apps/bot/src/commands/twitch/add-streamer.ts deleted file mode 100644 index 1d062666e..000000000 --- a/apps/bot/src/commands/twitch/add-streamer.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { MessageChannel } from '../../lib/structures/ExtendedClient'; -import { ApplyOptions } from '@sapphire/decorators'; -import { Command, CommandOptions, container } from '@sapphire/framework'; -import type { GuildChannel } from 'discord.js'; -import { isTextBasedChannel } from '@sapphire/discord.js-utilities'; -import { notify } from '../../lib/twitch/notifyChannels'; -import { trpcNode } from '../../trpc'; - -@ApplyOptions({ - name: 'add-streamer', - description: 'Add a Stream alert from your favorite Twitch streamer', - requiredUserPermissions: 'ModerateMembers', - preconditions: ['GuildOnly', 'isCommandDisabled'] -}) -export class AddStreamerCommand extends Command { - public override async chatInputRun( - interaction: Command.ChatInputCommandInteraction - ) { - const streamerName = interaction.options.getString('streamer-name', true); - const channelData = interaction.options.getChannel('channel-name', true); - const { client } = container; - - let isError = false; - let user; - try { - user = await client.twitch.api.getUser({ - login: streamerName, - token: client.twitch.auth.access_token - }); - } catch (error: any) { - isError = true; - if (error.status == 400) { - return await interaction.reply({ - content: `:x: "${streamerName}" was Invalid, Please try again.` - }); - } - if (error.status === 401) { - return await interaction.reply({ - content: `:x: You are not authorized to use this command.` - }); - } - if (error.status == 429) { - return await interaction.reply({ - content: ':x: Rate Limit exceeded. Please try again in a few minutes.' - }); - } - if (error.status == 500) { - return await interaction.reply({ - content: `:x: Twitch service's are currently unavailable. Please try again later.` - }); - } else { - return await interaction.reply({ - content: `:x: Something went wrong.` - }); - } - } - - if (isError) return; - if (!user) - return await interaction.reply({ - content: `:x: ${streamerName} was not Found` - }); - if (!isTextBasedChannel(channelData as GuildChannel)) - return await interaction.reply({ - content: `:x: Can't send messages to ${channelData.name}` - }); - - const guildDB = await trpcNode.guild.getGuild.query({ - id: interaction.guild!.id - }); - - if (!guildDB.guild) { - return await interaction.reply({ - content: `:x: Something went wrong.` - }); - } - - // check if streamer is already on notify list - if (guildDB?.guild.notifyList.includes(user.id)) - return await interaction.reply({ - content: `:x: ${user.display_name} is already on your Notification list` - }); - - // make sure channel is not already on notify list - for (const twitchChannel in client.twitch.notifyList) { - for (const channelToMsg of client.twitch.notifyList[twitchChannel] - .sendTo) { - const query = client.channels.cache.get(channelToMsg) as MessageChannel; - if (query) - if (query.guild.id == interaction.guild?.id) { - if (twitchChannel == user.id) - return await interaction.reply({ - content: `:x: **${user.display_name}** is already has a notification in **#${query.name}**` - }); - } - } - } - // make sure no one else is already sending alerts about this streamer - if (client.twitch.notifyList[user.id]?.sendTo.includes(channelData.id)) - return await interaction.reply({ - content: `:x: **${user.display_name}** is already messaging ${channelData.name}` - }); - - let channelArray; - if (client.twitch.notifyList[user.id]) - channelArray = [ - ...client.twitch.notifyList[user.id].sendTo, - ...[channelData.id] - ]; - else channelArray = [channelData.id]; - - // add notification to twitch object on client - client.twitch.notifyList[user.id] - ? (client.twitch.notifyList[user.id].sendTo = channelArray) - : (client.twitch.notifyList[user.id] = { - sendTo: [channelData.id], - live: false, - logo: user.profile_image_url, - messageSent: false, - messageHandler: {} - }); - - // add notification to database - await trpcNode.twitch.create.mutate({ - userId: user.id, - userImage: user.profile_image_url, - channelId: channelData.id, - sendTo: client.twitch.notifyList[user.id].sendTo - }); - - // add notification to guild on database - const concatedArray = guildDB.guild.notifyList.concat([user.id]); - - const guild = interaction.guild!; - - await trpcNode.twitch.createViaTwitchNotification.mutate({ - name: guild.name, - guildId: guild.id, - notifyList: concatedArray, - ownerId: guild.ownerId, - userId: interaction.user.id - }); - - await interaction.reply({ - content: `**${user.display_name}** Stream Notification will be sent to **#${channelData.name}**` - }); - const newQuery: string[] = []; - // pickup newly added entries - for (const key in client.twitch.notifyList) { - newQuery.push(key); - } - await notify(newQuery); - return; - } - - public override registerApplicationCommands( - registry: Command.Registry - ): void { - if (!process.env.TWITCH_CLIENT_ID || !process.env.TWITCH_CLIENT_SECRET) { - return; - } - - registry.registerChatInputCommand(builder => - builder - .setName(this.name) - .setDescription(this.description) - .addStringOption(option => - option - .setName('streamer-name') - .setDescription('What is the name of the Twitch streamer?') - .setRequired(true) - ) - .addChannelOption(option => - option - .setName('channel-name') - .setDescription( - 'What is the name of the Channel you would like the alert to be sent to?' - ) - .setRequired(true) - ) - ); - } -} diff --git a/apps/bot/src/commands/twitch/remove-streamer.ts b/apps/bot/src/commands/twitch/remove-streamer.ts deleted file mode 100644 index 8227c5a24..000000000 --- a/apps/bot/src/commands/twitch/remove-streamer.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command, CommandOptions, container } from '@sapphire/framework'; -import type { GuildChannel } from 'discord.js'; -import { isTextBasedChannel } from '@sapphire/discord.js-utilities'; -import { trpcNode } from '../../trpc'; - -@ApplyOptions({ - name: 'remove-streamer', - description: 'Add a Stream alert from your favorite Twitch streamer', - requiredUserPermissions: 'ModerateMembers', - preconditions: ['GuildOnly', 'isCommandDisabled'] -}) -export class RemoveStreamerCommand extends Command { - public override async chatInputRun( - interaction: Command.ChatInputCommandInteraction - ) { - const streamerName = interaction.options.getString('streamer-name', true); - const channelData = interaction.options.getChannel('channel-name', true); - const { client } = container; - - let user: any; - try { - user = await client.twitch.api.getUser({ - login: streamerName, - token: client.twitch.auth.access_token - }); - } catch (error: any) { - if (error.status == 400) { - return await interaction.reply({ - content: `:x: "${streamerName}" was Invalid, Please try again.` - }); - } - if (error.status == 429) { - return await interaction.reply({ - content: ':x: Rate Limit exceeded. Please try again in a few minutes.' - }); - } - if (error.status == 500) { - return await interaction.reply({ - content: `:x: Twitch service's are currently unavailable. Please try again later.` - }); - } else { - return await interaction.reply({ - content: `:x: Something went wrong.` - }); - } - } - - if (!user) - return await interaction.reply({ - content: `:x: ${streamerName} was not Found` - }); - if (!isTextBasedChannel(channelData as GuildChannel)) - return await interaction.reply({ - content: `:x: Cant sent messages to ${channelData.name}` - }); - - const guildDB = await trpcNode.guild.getGuild.query({ - id: interaction.guild!.id - }); - - const notifyDB = await trpcNode.twitch.findUserById.query({ - id: user.id - }); - - if (!guildDB.guild || !guildDB.guild.notifyList.includes(user.id)) - return await interaction.reply({ - content: `:x: **${user.display_name}** is not in your Notification list` - }); - - if (!notifyDB || !notifyDB.notification) - return await interaction.reply({ - content: `:x: **${user.display_name}** was not found in Database` - }); - - let found = false; - notifyDB.notification.channelIds.forEach(channel => { - if (channel == channelData.id) found = true; - }); - if (found === false) - return await interaction.reply({ - content: `:x: **${user.display_name}** is not assigned to **${channelData}**` - }); - - const filteredTwitchIds: string[] = guildDB.guild.notifyList.filter( - element => { - return element !== user.id; - } - ); - - await trpcNode.twitch.updateTwitchNotifications.mutate({ - guildId: interaction.guild!.id, - notifyList: filteredTwitchIds - }); - - const filteredChannelIds: string[] = - notifyDB.notification.channelIds.filter(element => { - return element !== channelData.id; - }); - - if (filteredChannelIds.length == 0) { - await trpcNode.twitch.delete.mutate({ - userId: user.id - }); - delete client.twitch.notifyList[user.id]; - } else { - await trpcNode.twitch.updateNotification.mutate({ - userId: user.id, - channelIds: filteredChannelIds - }); - - client.twitch.notifyList[user.id].sendTo = filteredChannelIds; - } - - await interaction.reply({ - content: `**${user.display_name}** Stream Notification will no longer be sent to **#${channelData.name}**` - }); - - return; - } - - public override registerApplicationCommands( - registry: Command.Registry - ): void { - if (!process.env.TWITCH_CLIENT_ID || !process.env.TWITCH_CLIENT_SECRET) { - return; - } - - registry.registerChatInputCommand(builder => - builder - .setName(this.name) - .setDescription(this.description) - .addStringOption(option => - option - .setName('streamer-name') - .setDescription('What is the name of the Twitch streamer?') - .setRequired(true) - ) - .addChannelOption(option => - option - .setName('channel-name') - .setDescription( - 'What is the name of the Channel you would like the Alert to be removed from?' - ) - .setRequired(true) - ) - ); - } -} diff --git a/apps/bot/src/commands/twitch/show-announcer-list.ts b/apps/bot/src/commands/twitch/show-announcer-list.ts deleted file mode 100644 index 419721242..000000000 --- a/apps/bot/src/commands/twitch/show-announcer-list.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command, CommandOptions, container } from '@sapphire/framework'; -import { EmbedBuilder } from 'discord.js'; -import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; -import { trpcNode } from '../../trpc'; -import { MessageChannel } from '../../lib/structures/ExtendedClient'; - -@ApplyOptions({ - name: 'show-announcer-list', - description: 'Display the Guilds Twitch notification list', - preconditions: ['GuildOnly', 'isCommandDisabled'] -}) -export class ShowAnnouncerListCommand extends Command { - public override async chatInputRun( - interaction: Command.ChatInputCommandInteraction - ) { - const { client } = container; - const interactionGuild = interaction.guild; - - // won't happen but just in case - if (!interactionGuild) { - return await interaction.reply(':x: Guild not found'); - } - - const guildDB = await trpcNode.guild.getGuild.query({ - id: interactionGuild.id - }); - - if (!guildDB || !guildDB.guild || guildDB.guild.notifyList.length === 0) { - return await interaction.reply(':x: No streamers are in your list'); - } - const icon = interactionGuild.iconURL(); - const baseEmbed = new EmbedBuilder().setColor('Purple').setAuthor({ - name: `${interactionGuild.name} - Twitch Alerts`, - iconURL: icon! - }); - - let users; - try { - users = await client.twitch.api.getUsers({ - ids: guildDB.guild.notifyList, - token: client.twitch.auth.access_token - }); - } catch (error: any) { - if (error.status == 429) { - return interaction.reply({ - content: ':x: Rate Limit exceeded. Please try again in a few minutes.' - }); - } - if (error.status == 500) { - return interaction.reply({ - content: `:x: Twitch service's are currently unavailable. Please try again later.` - }); - } else { - return interaction.reply({ - content: `:x: Something went wrong.` - }); - } - } - - const myList: object[] = []; - for (const streamer of users!) { - for (const channel in client.twitch.notifyList[streamer.id]?.sendTo) { - const guildChannel = client.channels.cache.get( - client.twitch.notifyList[streamer.id].sendTo[channel] - ) as MessageChannel; - if (guildChannel) - if (guildChannel.guild.id == interactionGuild.id) - myList.push({ - name: streamer.display_name, - channel: guildChannel.name - }); - } - } - new PaginatedFieldMessageEmbed() - .setTitleField('Streamers') - .setTemplate(baseEmbed) - .setItems(myList) - .formatItems( - (index: any) => `**${index.name}** Sending to **#${index.channel}**` - ) - .setItemsPerPage(10) - .make() - .run(interaction); - - return; - } - - public override registerApplicationCommands( - registry: Command.Registry - ): void { - if (!process.env.TWITCH_CLIENT_ID || !process.env.TWITCH_CLIENT_SECRET) { - return; - } - - registry.registerChatInputCommand(builder => - builder.setName(this.name).setDescription(this.description) - ); - } -} diff --git a/apps/bot/src/commands/twitch/twitch-status.ts b/apps/bot/src/commands/twitch/twitch-status.ts index f9df58b87..82d104c5e 100644 --- a/apps/bot/src/commands/twitch/twitch-status.ts +++ b/apps/bot/src/commands/twitch/twitch-status.ts @@ -1,7 +1,8 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions, container } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; -import Logger from '../../lib/logger'; +import Logger from '../../lib/logger.js'; @ApplyOptions({ name: 'twitch-status', @@ -107,7 +108,7 @@ export class TwitchStatusCommand extends Command { value: user.broadcaster_type != '' ? user.broadcaster_type.charAt(0).toUpperCase() + - user.broadcaster_type.slice(1) + user.broadcaster_type.slice(1) : 'Base', inline: true }); @@ -160,3 +161,18 @@ export class TwitchStatusCommand extends Command { ); } } + +export const help: CommandHelp = { + name: 'twitch-status', + category: 'twitch', + description: 'Check the status of your favorite streamer', + usage: '/twitch-status ', + examples: ['/twitch-status streamer: value'], + options: [ + { + name: 'streamer', + description: 'The Streamers Name', + required: true + } + ] +}; diff --git a/apps/bot/src/dataService.ts b/apps/bot/src/dataService.ts new file mode 100644 index 000000000..5b480e703 --- /dev/null +++ b/apps/bot/src/dataService.ts @@ -0,0 +1,486 @@ +import { BotDatabase } from '@master-bot/db'; +import type { + Guild, + Playlist, + Reminder, + Song, + SongInput, + TempChannel, + Ticket, + TwitchNotify, + User +} from '@master-bot/db'; + +/** + * In-process data service replacing the deleted tRPC client. + * + * Mirrors the router procedure I/O shapes 1:1 (see the deleted routers under + * `packages/api/src/routers`) but resolves everything synchronously against + * the shared `BotDatabase` SQLite singleton. No HTTP, no serialization. + */ +function parseStringArray(value: string | string[] | null | undefined): string[] { + if (Array.isArray(value)) return value; + try { + const parsed = JSON.parse(value || '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +type ParsedTwitchNotify = Omit & { channelIds: string[] }; + +function parseTwitchNotification(notification: TwitchNotify): ParsedTwitchNotify { + return { + ...notification, + channelIds: parseStringArray(notification.channelIds) + }; +} + +export const dataService = { + user: { + async create(input: { + id: string; + name: string; + }): Promise<{ user: User }> { + const user = BotDatabase.getInstance().upsertUser(input.id, input.name); + return { user }; + } + }, + + playlist: { + async getPlaylist(input: { + userId: string; + name: string; + }): Promise<{ playlist: (Playlist & { songs: Song[] }) | null }> { + const playlist = BotDatabase.getInstance().getPlaylist( + input.userId, + input.name + ); + return { playlist }; + }, + + async getAll(input: { + userId: string; + }): Promise<{ playlists: (Playlist & { songs: Song[] })[] }> { + const playlists = BotDatabase.getInstance().getAllPlaylists(input.userId); + return { playlists }; + }, + + async create(input: { + userId: string; + name: string; + }): Promise<{ playlist: Playlist }> { + const playlist = BotDatabase.getInstance().createPlaylist( + input.userId, + input.name + ); + return { playlist }; + }, + + async delete(input: { + userId: string; + name: string; + }): Promise<{ playlist: { count: number } }> { + const playlist = BotDatabase.getInstance().deletePlaylist( + input.userId, + input.name + ); + return { playlist }; + } + }, + + song: { + async createMany(input: { + songs: SongInput[]; + }): Promise<{ songsCreated: { count: number } }> { + const songsCreated = BotDatabase.getInstance().createSongs(input.songs); + return { songsCreated }; + }, + + async delete(input: { id: number }): Promise<{ song: Song | null }> { + const song = BotDatabase.getInstance().deleteSong(input.id); + return { song }; + } + }, + + guild: { + async getGuild(input: { id: string }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().getGuild(input.id); + return { guild }; + }, + + async create(input: { + id: string; + ownerId: string; + name: string; + }): Promise<{ guild: Guild }> { + const guild = BotDatabase.getInstance().upsertGuild( + input.id, + input.ownerId, + input.name + ); + return { guild }; + }, + + async delete(input: { id: string }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().deleteGuild(input.id); + return { guild }; + }, + + async updateVolume(input: { + guildId: string; + volume: number; + }): Promise { + BotDatabase.getInstance().updateGuildVolume(input.guildId, input.volume); + }, + + async setLogChannel(input: { + guildId: string; + channelId: string | null; + }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().setGuildLogChannel( + input.guildId, + input.channelId + ); + return { guild }; + }, + + async toggleLogChannel(input: { + guildId: string; + status: boolean; + }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().toggleGuildLogChannel( + input.guildId, + input.status + ); + return { guild }; + } + }, + + hub: { + async getTempChannel(input: { + guildId: string; + ownerId: string; + }): Promise<{ tempChannel: TempChannel | null }> { + const tempChannel = BotDatabase.getInstance().getTempChannel( + input.guildId, + input.ownerId + ); + return { tempChannel }; + }, + + async createTempChannel(input: { + guildId: string; + ownerId: string; + channelId: string; + }): Promise<{ tempChannel: TempChannel }> { + const tempChannel = BotDatabase.getInstance().createTempChannel( + input.guildId, + input.ownerId, + input.channelId + ); + return { tempChannel }; + }, + + async deleteTempChannel(input: { + channelId: string; + }): Promise<{ tempChannel: TempChannel | null }> { + const tempChannel = + BotDatabase.getInstance().deleteTempChannelByChannelId(input.channelId); + return { tempChannel }; + } + }, + + twitch: { + async getAll(): Promise<{ + notifications: ParsedTwitchNotify[]; + }> { + const notifications = + BotDatabase.getInstance() + .getAllTwitchNotifications() + .map(parseTwitchNotification); + return { notifications }; + }, + + async findUserById(input: { + id: string; + }): Promise<{ + notification: ParsedTwitchNotify | null; + }> { + const notification = BotDatabase.getInstance().getTwitchNotification( + input.id + ); + return { + notification: notification + ? parseTwitchNotification(notification) + : null + }; + }, + + async create(input: { + userId: string; + userImage: string; + channelId: string; + sendTo: string[]; + }): Promise { + BotDatabase.getInstance().upsertTwitchNotification( + input.userId, + input.userImage, + input.sendTo, + input.channelId + ); + }, + + async updateNotification(input: { + userId: string; + channelIds: string[]; + }): Promise<{ + notification: ParsedTwitchNotify | null; + }> { + const notification = BotDatabase.getInstance().updateTwitchNotification( + input.userId, + input.channelIds + ); + return { + notification: notification + ? parseTwitchNotification(notification) + : null + }; + }, + + async delete(input: { + userId: string; + }): Promise<{ notification: TwitchNotify | null }> { + const notification = + BotDatabase.getInstance().deleteTwitchNotification(input.userId); + return { notification }; + }, + + async createViaTwitchNotification(input: { + guildId: string; + userId: string; + ownerId: string; + name: string; + notifyList: string[]; + }): Promise { + BotDatabase.getInstance().upsertGuildFull( + input.guildId, + input.ownerId, + input.name, + input.notifyList + ); + }, + + async updateTwitchNotifications(input: { + guildId: string; + notifyList: string[]; + }): Promise { + const db = BotDatabase.getInstance(); + const guild = db.getGuild(input.guildId); + if (guild) { + db.upsertGuildFull( + input.guildId, + guild.ownerId, + guild.name, + input.notifyList + ); + } + }, + + async updateNotificationStatus(input: { + userId: string; + live: boolean; + sent: boolean; + }): Promise<{ + notification: ParsedTwitchNotify | null; + }> { + const notification = + BotDatabase.getInstance().updateTwitchNotificationStatus( + input.userId, + input.live, + input.sent + ); + return { + notification: notification + ? parseTwitchNotification(notification) + : null + }; + } + }, + + command: { + async getDisabledCommands(input: { + guildId: string; + }): Promise<{ disabledCommands: string[] }> { + const guild = BotDatabase.getInstance().getGuild(input.guildId); + return { + disabledCommands: guild + ? parseStringArray(guild.disabledCommands) + : [] + }; + } + }, + + tickets: { + async getConfig(input: { + guildId: string; + }): Promise<{ guild: Guild | null; recentTickets: Ticket[] }> { + const db = BotDatabase.getInstance(); + return { + guild: db.getGuild(input.guildId), + recentTickets: db.getRecentTickets(input.guildId, 10) + }; + }, + + async setChannel(input: { + guildId: string; + channelId: string | null; + }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().setTicketChannel( + input.guildId, + input.channelId + ); + return { guild }; + }, + + async toggle(input: { + guildId: string; + status: boolean; + }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().toggleTicket( + input.guildId, + input.status + ); + return { guild }; + }, + + async setTranscriptChannel(input: { + guildId: string; + channelId: string | null; + }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().setTicketTranscriptChannel( + input.guildId, + input.channelId + ); + return { guild }; + }, + + async setRole(input: { + guildId: string; + roleId: string | null; + }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().setTicketRole( + input.guildId, + input.roleId + ); + return { guild }; + }, + + async createTicket(input: { + guildId: string; + threadId: string; + creatorId: string; + }): Promise<{ ticket: Ticket }> { + const ticket = BotDatabase.getInstance().createTicket( + input.guildId, + input.threadId, + input.creatorId + ); + return { ticket }; + }, + + async closeTicket(input: { + threadId: string; + }): Promise<{ ticket: Ticket | null }> { + const ticket = BotDatabase.getInstance().closeTicket(input.threadId); + return { ticket }; + } + }, + + welcome: { + async setChannel(input: { + guildId: string; + channelId: string; + }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().setWelcomeChannel( + input.guildId, + input.channelId + ); + return { guild }; + }, + + async setMessage(input: { + guildId: string; + message: string; + }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().setWelcomeMessage( + input.guildId, + input.message + ); + return { guild }; + }, + + async toggle(input: { + guildId: string; + status: boolean; + }): Promise<{ guild: Guild | null }> { + const guild = BotDatabase.getInstance().toggleWelcome( + input.guildId, + input.status + ); + return { guild }; + } + }, + + reminder: { + async getDueReminders(input: { + beforeIsoDate: string; + }): Promise<{ reminders: Reminder[] }> { + const reminders = BotDatabase.getInstance().getDueReminders( + input.beforeIsoDate + ); + return { reminders }; + }, + + async create(input: { + userId: string; + event: string; + description: string | null; + dateTime: string; + repeat: string | null; + timeOffset: number; + }): Promise<{ reminder: Reminder }> { + const reminder = BotDatabase.getInstance().createReminder(input); + return { reminder }; + }, + + async delete(input: { + userId: string; + event: string; + }): Promise<{ reminder: { count: number } }> { + const reminder = BotDatabase.getInstance().deleteRemindersByUserAndEvent( + input.userId, + input.event + ); + return { reminder }; + }, + + async getByUserId(input: { + userId: string; + }): Promise<{ + reminders: { + id: number; + event: string; + dateTime: string; + description: string | null; + }[]; + }> { + const reminders = + BotDatabase.getInstance().getRemindersByUserIdSelect(input.userId); + return { reminders }; + } + } +}; + +export type DataService = typeof dataService; \ No newline at end of file diff --git a/apps/bot/src/env.ts b/apps/bot/src/env.ts index 2985eec75..44841590d 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -1,33 +1,398 @@ -import { createEnv } from '@t3-oss/env-core'; -import { z } from 'zod'; - -export const env = createEnv({ - /* - * Specify what prefix the client-side variables must have. - * This is enforced both on type-level and at runtime. - */ - clientPrefix: 'PUBLIC_', - server: { - DISCORD_TOKEN: z.string(), - TENOR_API: z.string(), - RAWG_API: z.string().optional(), - // Redis - REDIS_HOST: z.string().optional(), - REDIS_PORT: z.string().optional(), - REDIS_PASSWORD: z.string().optional(), - REDIS_DB: z.string().optional(), - // Lavalink - LAVA_HOST: z.string().optional(), - LAVA_PORT: z.string().optional(), - LAVA_PASS: z.string().optional(), - LAVA_SECURE: z.string().optional(), - SPOTIFY_CLIENT_ID: z.string().optional(), - SPOTIFY_CLIENT_SECRET: z.string().optional() - }, - client: {}, - /** - * What object holds the environment variables at runtime. - * Often `process.env` or `import.meta.env` - */ - runtimeEnv: process.env -}); +/** + * apps/bot/src/env.ts + * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + * Central environment handler for the Master-Bot subsystem. + * + * All bot and dashboard files import their env keys from here instead of + * reading process.env directly. This is a HELIX-faithful alignment: + * โ€ข Typed, defaulted accessors โ€” no `process.env.X || 'default'` scatter + * โ€ข NEXTAUTH_URL / NEXTAUTH_INTERNAL_URL are AUTO-RESOLVED from PORT โ€” + * the user never provides them, exactly like HELIX + * โ€ข A single place to add validation or change key names + * โ€ข saveBotEnvValue() for writing config back to .env at runtime + * + * The ONLY extra keys beyond HELIX are those required for Master-Bot's + * Lavalink and external API services (KLIPY/NEWS/IGDB/Twitch/Spotify/etc.). + * + * Load order (HELIX clone): + * /.env โ†’ cwd/.env โ†’ home overrides โ†’ dotenv CWD fallback + * โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import dotenv from 'dotenv'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export function resolveBotRootDir(): string { + const probes = [ + process.cwd(), + path.resolve(process.cwd(), '..'), + path.resolve(process.cwd(), '..', '..'), + __dirname, + path.resolve(__dirname, '..'), + path.resolve(__dirname, '..', '..'), + path.resolve(__dirname, '..', '..', '..'), + path.resolve(__dirname, '..', '..', '..', '..') + ]; + for (const dir of probes) { + try { + const rootPkg = path.join(dir, 'package.json'); + if (fs.existsSync(rootPkg)) { + const pkg = JSON.parse(fs.readFileSync(rootPkg, 'utf-8')); + if (pkg.name === 'master-bot-turbo' || pkg.workspaces || pkg.name === '@master-bot/bot') { + return dir; + } + } + if (fs.existsSync(path.join(dir, '.env')) && !dir.endsWith('dist') && !dir.endsWith('src')) { + return dir; + } + } catch { + // Ignore unreadable or invalid package.json during probing + } + } + return process.cwd(); +} + +export const BOT_ROOT_DIR = resolveBotRootDir(); + +// โ”€โ”€โ”€ Bootstrap โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +let _loaded = false; + +export function loadBotEnv(): void { + if (_loaded) return; + _loaded = true; + + const candidates: string[] = [ + path.resolve(BOT_ROOT_DIR, '.env'), + path.resolve(process.cwd(), '.env'), + path.resolve(process.cwd(), '..', '.env'), + path.resolve(__dirname, '.env'), + path.resolve(__dirname, '..', '.env'), + path.resolve(__dirname, '..', '..', '.env'), + path.resolve(os.homedir(), '.master-bot', '.env'), + path.resolve(os.homedir(), '.env') + ]; + + for (const p of candidates) { + try { + if (fs.existsSync(p)) { + dotenv.config({ path: p }); + } + } catch { + // Ignore unreadable .env candidates + } + } + // Standard dotenv CWD lookup as final fallback + dotenv.config(); +} + +// Load immediately on import +loadBotEnv(); + +// โ”€โ”€โ”€ Discord Credentials โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function stripQuotesAndAuth(value: string, stripBotPrefix = false): string { + let cleaned = value.trim(); + if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || (cleaned.startsWith("'") && cleaned.endsWith("'"))) { + cleaned = cleaned.slice(1, -1).trim(); + } + if (stripBotPrefix && cleaned.startsWith('Bot ')) { + cleaned = cleaned.slice(4).trim(); + } + return cleaned; +} + +/** Discord Bot Token โ€” required for gateway connection. */ +export function getBotToken(): string { + return stripQuotesAndAuth( + process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || process.env.BOT_TOKEN || process.env.TOKEN || '', + true + ); +} + +/** Discord Application Client ID โ€” required for OAuth2 and slash commands. */ +export function getClientId(): string { + return stripQuotesAndAuth( + process.env.DISCORD_CLIENT_ID || process.env.CLIENT_ID || process.env.DISCORD_APP_ID || process.env.APPLICATION_ID || process.env.APP_ID || '' + ); +} + +/** Discord Application Client Secret โ€” required for the dashboard OAuth2 flow. */ +export function getClientSecret(): string { + return stripQuotesAndAuth( + process.env.DISCORD_CLIENT_SECRET || process.env.CLIENT_SECRET || process.env.DISCORD_SECRET || '' + ); +} + +/** Discord server / user ID treated as the bot owner. */ +export function getOwnerId(): string { + return ( + process.env.DISCORD_OWNER_ID || process.env.BOT_OWNER_ID || process.env.OWNER_ID || '' + ).trim(); +} + +// โ”€โ”€โ”€ Ports & URLs (auto-resolved, HELIX-faithful) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * HTTP port the bot, embedded dashboard, and OAuth2 callback server all share. + * A single unified `PORT` key only โ€” the dashboard no longer runs as a + * separate process with its own port. Defaults to 3000 (Master-Bot's port; + * HELIX uses 5000 so the two never conflict). + */ +export function getPort(): number { + const raw = process.env.PORT; + if (raw) { + const n = parseInt(raw, 10); + if (!isNaN(n)) return n; + } + return 3000; +} + +/** + * Normalizes a callback URL to its BASE URL form. + * Accepts either a bare base (`https://app.example.com`) or the full callback + * path (`https://app.example.com/api/auth/callback/discord`) and strips any + * trailing auth/callback path. + */ +export function normalizeCallbackBaseUrl(raw: string): string { + const trimmed = (raw || '').trim(); + if (!trimmed) return ''; + const lower = trimmed.toLowerCase(); + const marker = lower.indexOf('/api/auth/callback/'); + const base = marker !== -1 ? trimmed.slice(0, marker) : trimmed; + return base.replace(/\/+$/, ''); +} + +/** + * Public-facing NextAuth / Dashboard URL (e.g. `https://bot.example.com`). + * AUTO-RESOLVED just like HELIX: explicit `NEXTAUTH_URL` for public + * deployments, else `DISCORD_CALLBACK_URL`, else `http://localhost:`. + * Users never need to provide NEXTAUTH_URL for local setups. + */ +export function getNextAuthUrl(): string { + const port = getPort(); + const explicit = (process.env.NEXTAUTH_URL || '').trim(); + if (explicit) { + const clean = explicit.replace(/\/+$/, ''); + const url = clean.includes('://') + ? clean + : clean.includes('localhost') || clean.includes('127.0.0.1') + ? `http://${clean}` + : `https://${clean}`; + try { + const u = new URL(url); + if (!u.port && (u.hostname === 'localhost' || u.hostname === '127.0.0.1')) { + u.port = String(port); + } + return u.toString().replace(/\/+$/, ''); + } catch { + return url; + } + } + + const explicitCallback = normalizeCallbackBaseUrl(process.env.DISCORD_CALLBACK_URL || ''); + if (explicitCallback) { + return explicitCallback; + } + + return `http://localhost:${port}`; +} + +/** + * Internal URL NextAuth uses for server-side self-requests. + * AUTO-RESOLVED (defaults to `http://localhost:`). + */ +export function getNextAuthInternalUrl(): string { + const port = getPort(); + const raw = (process.env.NEXTAUTH_INTERNAL_URL || 'http://localhost').trim().replace(/\/+$/, ''); + try { + const u = new URL(raw.includes('://') ? raw : `http://${raw}`); + if (!u.port) u.port = String(port); + return u.toString().replace(/\/+$/, ''); + } catch { + return `http://localhost:${port}`; + } +} + +/** + * Base OAuth2 callback URL (no trailing slash). + * Returns `DISCORD_CALLBACK_URL` if set, otherwise auto-resolves to `getNextAuthUrl()`. + */ +export function getCallbackUrl(): string { + const explicit = normalizeCallbackBaseUrl(process.env.DISCORD_CALLBACK_URL || ''); + if (explicit) { + return explicit; + } + return getNextAuthUrl(); +} + +/** Pre-built administrator bot invite URL. Quotes are stripped automatically. */ +export function getInviteUrl(): string { + const raw = (process.env.NEXT_PUBLIC_INVITE_URL || '').trim(); + const invite = stripQuotesAndAuth(raw); + if (!invite) { + const clientId = getClientId(); + if (clientId && clientId !== 'yourclientid') { + return `https://discord.com/oauth2/authorize?client_id=${clientId}&permissions=8&scope=bot%20applications.commands`; + } + } + return invite; +} + +/** HMAC secret for signing NextAuth session tokens. */ +export function getNextAuthSecret(): string { + return process.env.NEXTAUTH_SECRET || 'master_bot_dashboard_secret_key_32_bytes_min'; +} + +/** + * Absolute path to the SQLite database file. + * Defaults to `/data/bot.sqlite`, overridable via `DISCORD_DB_PATH`. + */ +export function getDbPath(): string { + const defaultDir = path.resolve(BOT_ROOT_DIR, 'data'); + try { + fs.mkdirSync(defaultDir, { recursive: true }); + } catch { + // Directory may already exist or be unwritable โ€” proceed regardless + } + return process.env.DISCORD_DB_PATH || path.resolve(defaultDir, 'bot.sqlite'); +} + +// โ”€โ”€โ”€ Master-Bot Lavalink & API Service Extras โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function toBool(value: string | undefined, fallback: boolean): boolean { + if (value === undefined) return fallback; + return value.toLowerCase() === 'true'; +} + +/** MasterBot Feature Toggles */ +export function isLavalinkEnabled(): boolean { + return toBool(process.env.LAVA_ENABLED, true); +} + +export function isGifsEnabled(): boolean { + return toBool(process.env.GIFS_ENABLED, true); +} + +export function isTwitchEnabled(): boolean { + return toBool(process.env.TWITCH_ENABLED, true); +} + +export function isNewsEnabled(): boolean { + return toBool(process.env.NEWS_ENABLED, true); +} + +export function isIgdbEnabled(): boolean { + return toBool(process.env.IGDB_ENABLED, true); +} + +export interface LavalinkConfig { + external: boolean; + host: string; + port: number; + password: string; + secure: boolean; + clientId: string; +} + +export function getLavalinkConfig(): LavalinkConfig { + return { + external: toBool(process.env.LAVA_EXTERNAL, false), + host: process.env.LAVA_HOST || '127.0.0.1', + port: process.env.LAVA_PORT ? parseInt(process.env.LAVA_PORT, 10) : 2333, + password: process.env.LAVA_PASS || 'youshallnotpass', + secure: toBool(process.env.LAVA_SECURE, false), + clientId: process.env.DISCORD_CLIENT_ID || '' + }; +} + +export interface ApiServiceKeys { + klipyApi: string; + newsApi: string; + youtubeApiKey: string; + youtubeRefreshToken: string; + youtubeCipherUrl: string; + youtubeCipherPassword: string; + spotifyClientId: string; + spotifyClientSecret: string; + soundcloudClientId: string; + soundcloudClientSecret: string; + igdbEnabled: boolean; +} + +export function getApiServiceKeys(): ApiServiceKeys { + return { + klipyApi: process.env.KLIPY_API || '', + newsApi: process.env.NEWS_API || '', + youtubeApiKey: process.env.YOUTUBE_API_KEY || '', + youtubeRefreshToken: process.env.YOUTUBE_REFRESH_TOKEN || '', + youtubeCipherUrl: process.env.YOUTUBE_CIPHER_URL || '', + youtubeCipherPassword: process.env.YOUTUBE_CIPHER_PASSWORD || '', + spotifyClientId: process.env.SPOTIFY_CLIENT_ID || '', + spotifyClientSecret: process.env.SPOTIFY_CLIENT_SECRET || '', + soundcloudClientId: process.env.SOUNDCLOUD_CLIENT_ID || '', + soundcloudClientSecret: process.env.SOUNDCLOUD_CLIENT_SECRET || '', + igdbEnabled: isIgdbEnabled() + }; +} + +// โ”€โ”€โ”€ Convenience snapshot โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +export interface BotEnvConfig { + botToken: string; + clientId: string; + clientSecret: string; + ownerId: string; + callbackUrl: string; + inviteUrl: string; + port: number; + nextAuthUrl: string; + nextAuthInternalUrl: string; + nextAuthSecret: string; + dbPath: string; +} + +/** Returns a snapshot of all bot env values at the moment of calling. */ +export function getBotEnv(): BotEnvConfig { + return { + botToken: getBotToken(), + clientId: getClientId(), + clientSecret: getClientSecret(), + ownerId: getOwnerId(), + callbackUrl: getCallbackUrl(), + inviteUrl: getInviteUrl(), + port: getPort(), + nextAuthUrl: getNextAuthUrl(), + nextAuthInternalUrl: getNextAuthInternalUrl(), + nextAuthSecret: getNextAuthSecret(), + dbPath: getDbPath() + }; +} + +// โ”€โ”€โ”€ Write helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * Write or update a single key in the project `.env` file. + * Also sets `process.env[key]` immediately so the running process reflects + * the new value without a restart. + */ +export function saveBotEnvValue(key: string, value: string, envPath?: string): string { + const target = envPath || path.resolve(BOT_ROOT_DIR, '.env'); + let content = ''; + try { + content = fs.existsSync(target) ? fs.readFileSync(target, 'utf-8') : ''; + } catch { + // Fall through with empty content + } + const regex = new RegExp(`^${key}=.*$`, 'm'); + content = regex.test(content) ? content.replace(regex, `${key}=${value}`) : `${content.trimEnd()}\n${key}=${value}\n`; + fs.writeFileSync(target, content, 'utf-8'); + process.env[key] = value; + return target; +} \ No newline at end of file diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 942c905bd..4a94edc2b 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,110 +1,281 @@ -import { ExtendedClient } from './lib/structures/ExtendedClient'; -import { env } from './env'; -import { load } from '@lavaclient/spotify'; +import { setDatabasePath } from '@master-bot/db'; +import { ExtendedClient } from './lib/structures/ExtendedClient.js'; +import { + getBotToken, + getDbPath, + isLavalinkEnabled, + isTwitchEnabled +} from './env.js'; +import { BotCallbackServer } from './server.js'; import { ApplicationCommandRegistries, + Events, RegisterBehavior } from '@sapphire/framework'; -import { ActivityType } from 'discord.js'; -import Logger from './lib/logger'; -import { notify } from './lib/twitch/notifyChannels'; -import { trpcNode } from './trpc'; +import { ReminderManager } from './lib/reminders/ReminderManager.js'; +import { StatusManager } from './lib/presence/StatusManager.js'; +import Logger from './lib/logger.js'; +import { notify } from './lib/twitch/notifyChannels.js'; +import { dataService } from './dataService.js'; ApplicationCommandRegistries.setDefaultBehaviorWhenNotIdentical( RegisterBehavior.Overwrite ); -if (env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET) { - load({ - client: { - id: env.SPOTIFY_CLIENT_ID, - secret: env.SPOTIFY_CLIENT_SECRET - }, - autoResolveYoutubeTracks: true - }); -} +const lavalinkEnabled = isLavalinkEnabled(); -const client = new ExtendedClient(); +function registerClientEvents(client: ExtendedClient) { + client.on(Events.ClientReady, async () => { + if (!client.user) return; -client.on('ready', async () => { - client.music.connect(client.user!.id); - client.user?.setActivity('/', { - type: ActivityType.Watching - }); +if (lavalinkEnabled) { + try { + await client.music.init({ + id: client.user.id, + username: client.user.username + }); + Logger.info('Lavalink client initialized successfully.'); + } catch (err) { + Logger.error('Failed to initialize Lavalink client: ', err); + } + } else { + Logger.info( + 'Lavalink audio engine is currently disabled while music commands undergo upgrades.' + ); + } - client.user?.setStatus('online'); - const token = client.twitch.auth.access_token; - if (!token) return; + // Initialize dynamic rotating presence status + StatusManager.start(client); - // happens to be the first DB call at start up - try { - const notifyDB = await trpcNode.twitch.getAll.query(); - - const query: string[] = []; - for (const user of notifyDB.notifications) { - query.push(user.twitchId); - client.twitch.notifyList[user.twitchId] = { - sendTo: user.channelIds, - logo: user.logo, - live: user.live, - messageSent: user.sent, - messageHandler: {} + // Initialize Reminder Manager scheduler + ReminderManager.start(client); + + const twitchEnabled = isTwitchEnabled(); + + if ( + twitchEnabled && + process.env.TWITCH_CLIENT_ID && + process.env.TWITCH_CLIENT_SECRET + ) { + const initTwitch = async () => { + try { + const notifyDB = await dataService.twitch.getAll(); + const query = notifyDB.notifications.map(user => { + client.twitch.notifyList[user.twitchId] = { + sendTo: user.channelIds, + logo: user.logo, + live: user.live, + messageSent: user.sent, + messageHandler: {} + }; + return user.twitchId; + }); + + if (query.length > 0) { + await notify(query); + } + + setInterval(async () => { + try { + const newQuery = Object.keys(client.twitch.notifyList); + if (newQuery.length > 0) { + await notify(newQuery); + } + } catch (intervalErr) { + Logger.error('Twitch notification polling error: ', intervalErr); + } + }, 60 * 1000); + } catch (err) { + Logger.error('Twitch database sync error: ', err); + } }; + + // If access token is already available, run immediately; otherwise wait briefly for auth + if (client.twitch.auth.access_token) { + void initTwitch(); + } else { + setTimeout(() => void initTwitch(), 3000); + } } - await notify(query).then(() => - setInterval(async () => { - const newQuery: string[] = []; - // pickup newly added entries - for (const key in client.twitch.notifyList) { - newQuery.push(key); - } - await notify(newQuery); - }, 60 * 1000) + }); + + // Sapphire Framework Error Events + client.on(Events.ChatInputCommandError, (error, payload) => { + Logger.error( + `Command Chat Input Error [${payload?.command?.name || 'unknown'}]: `, + error + ); + }); + + client.on(Events.ContextMenuCommandError, (error, payload) => { + Logger.error( + `Command Context Menu Error [${payload?.command?.name || 'unknown'}]: `, + error + ); + }); + + client.on(Events.CommandAutocompleteInteractionError, (error, payload) => { + Logger.error( + `Command Autocomplete Error [${payload?.command?.name || 'unknown'}]: `, + error + ); + }); + + client.on(Events.CommandApplicationCommandRegistryError, (error, command) => { + Logger.error( + `Command Registry Error [${command?.name || 'unknown'}]: `, + error + ); + }); + + client.on(Events.MessageCommandError, (error, payload) => { + Logger.error( + `Message Command Error [${payload?.command?.name || 'unknown'}]: `, + error + ); + }); + + client.on(Events.InteractionHandlerError, (error, payload) => { + Logger.error( + `Interaction Handler Error [${payload?.handler?.name || 'unknown'}]: `, + error + ); + }); + + client.on(Events.InteractionHandlerParseError, (error, payload) => { + Logger.error( + `Interaction Handler Parse Error [${payload?.handler?.name || 'unknown'}]: `, + error ); - } catch (err) { - Logger.error('Prisma ' + err); + }); + + client.on(Events.ListenerError, (error, payload) => { + Logger.error( + `Client Listener Error [${payload?.piece?.name || 'unknown'}]: `, + error + ); + }); + + // Lavalink Node & Track Event Handlers (Gated behind lavalinkEnabled) + if (lavalinkEnabled) { + client.music.nodeManager.on('connect', node => { + Logger.info( + `Lavalink Node [${node?.id || 'main'}] connected successfully.` + ); + }); + + client.music.nodeManager.on('error', (node, err) => { + const errMsg = String((err as any)?.message || err); + if (errMsg.includes('ECONNREFUSED')) { + Logger.warn( + `Lavalink Node [${node?.id || 'main'}] initial connection pending (server starting up)...` + ); + } else { + Logger.error(`Lavalink Node Error [${node?.id || 'unknown'}]: `, err); + } + }); + + client.music.on('trackError', async (player, track, payload) => { + Logger.error( + `Playback Error on Guild [${player.guildId}] for track "${track?.info?.title || 'Unknown'}": `, + payload?.error || payload + ); + const queue = client.music.queues.get(player.guildId); + if (queue) { + const channel = await queue.getTextChannel(); + if (channel) { + await channel + .send({ + content: `:x: Playback failed for [**${track?.info?.title || 'Track'}**](<${track?.info?.uri || ''}>). Skipping to next track...`, + flags: ['SuppressEmbeds'] + }) + .catch(() => {}); + } + await queue.next(); + } + }); + + client.music.on('trackStuck', async (player, track, payload) => { + Logger.warn( + `Track Stuck on Guild [${player.guildId}] for track "${track?.info?.title || 'Unknown'}": `, + payload + ); + const queue = client.music.queues.get(player.guildId); + if (queue) { + const channel = await queue.getTextChannel(); + if (channel) { + await channel + .send({ + content: `:warning: Track [**${track?.info?.title || 'Track'}**](<${track?.info?.uri || ''}>) became stuck. Skipping to next track...`, + flags: ['SuppressEmbeds'] + }) + .catch(() => {}); + } + await queue.next(); + } + }); + + const handleTrackCompletion = async ( + player: any, + _track: any, + payload: any + ) => { + const reason = (payload?.reason || '').toLowerCase(); + if (reason === 'replaced' || reason === 'cleanup') return; + + const queue = client.music.queues.get(player.guildId); + if (queue) { + if (queue.skipped) { + queue.skipped = false; + return; + } + await queue.next(); + } + }; + + client.music.on('trackEnd', handleTrackCompletion); } -}); - -client.on('chatInputCommandError', err => { - console.log('Command Chat Input ' + err); -}); -client.on('contextMenuCommandError', err => { - console.log('Command Context Menu ' + err); -}); -client.on('commandAutocompleteInteractionError', err => { - console.log('Command Autocomplete ' + err); -}); -client.on('commandApplicationCommandRegistryError', err => { - console.log('Command Registry ' + err); -}); -client.on('messageCommandError', err => { - console.log('Command ' + err); -}); -client.on('interactionHandlerError', err => { - console.log('Interaction ' + err); -}); -client.on('interactionHandlerParseError', err => { - console.log('Interaction Parse ' + err); -}); - -client.on('listenerError', err => { - console.log('Client Listener ' + err); -}); - -// LavaLink -client.music.on('error', err => { - console.log('LavaLink ' + err); -}); +} const main = async () => { + setDatabasePath(getDbPath()); + + let client = new ExtendedClient({ withPrivilegedIntents: true }); + registerClientEvents(client); + try { - await client.login(env.DISCORD_TOKEN); - } catch (error) { - console.log('Bot errored out', error); - client.destroy(); - process.exit(1); + await Promise.all([client.login(getBotToken()), new BotCallbackServer().start()]); + } catch (error: any) { + const errorStr = String(error?.message || error); + if ( + errorStr.includes('DisallowedIntents') || + errorStr.includes('DISALLOWED_INTENTS') || + errorStr.includes('Privileged intent') || + error?.code === 'DisallowedIntents' + ) { + Logger.warn( + 'Privileged Gateway Intent (GuildMembers) was disallowed by Discord Developer Portal. Automatically falling back to standard intents...' + ); + client.destroy(); + client = new ExtendedClient({ withPrivilegedIntents: false }); + registerClientEvents(client); + try { + await Promise.all([client.login(getBotToken()), new BotCallbackServer().start()]); + Logger.info( + 'Master-Bot successfully logged in with standard Gateway intents.' + ); + } catch (fallbackError) { + Logger.error('Bot failed fallback login: ', fallbackError); + client.destroy(); + process.exit(1); + } + } else { + Logger.error('Bot failed to login / errored out: ', error); + client.destroy(); + process.exit(1); + } } }; void main(); + diff --git a/apps/bot/src/lib/constants.ts b/apps/bot/src/lib/constants.ts index 97147f063..dcb38ce05 100644 --- a/apps/bot/src/lib/constants.ts +++ b/apps/bot/src/lib/constants.ts @@ -1,4 +1,7 @@ -import { join } from 'path'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); export const rootDir = join(__dirname, '..', '..'); -export const srcDir = join(rootDir, 'src'); +export const srcDir = join(rootDir, 'src'); \ No newline at end of file diff --git a/apps/bot/src/lib/games/connect-4.ts b/apps/bot/src/lib/games/connect-4.ts index fc584e3e0..7d65ff90e 100644 --- a/apps/bot/src/lib/games/connect-4.ts +++ b/apps/bot/src/lib/games/connect-4.ts @@ -9,8 +9,8 @@ import { Message, Colors } from 'discord.js'; -import { playersInGame } from '../../commands/other/games'; -import Logger from '../logger'; +import { playersInGame } from '../../commands/other/games.js'; +import Logger from '../logger.js'; export class Connect4Game { public async connect4( @@ -32,7 +32,7 @@ export class Connect4Game { }); const player1Piece = new Image(); - player1Piece.src = Buffer.from(await player1Image.data); + player1Piece.src = new Uint8Array(await player1Image.data); const player2Avatar = player2!.displayAvatarURL({ extension: 'jpg' @@ -43,7 +43,7 @@ export class Connect4Game { url: player2Avatar }); const player2Piece = new Image(); - player2Piece.src = Buffer.from(await player2Image.data); + player2Piece.src = new Uint8Array(await player2Image.data); await game(player1, player2!); async function game(player1: User, player2: User) { @@ -87,7 +87,7 @@ export class Connect4Game { .setFooter({ text: 'Incase of invisible board click ๐Ÿ”„' }) .setTimestamp(); - await interaction.channel + await (interaction.channel as any) ?.send({ embeds: [Embed] }) .then(async (message: Message) => { const embed = new EmbedBuilder(message.embeds[0].data); @@ -311,7 +311,7 @@ export class Connect4Game { } } - return await interaction.channel + return await (interaction.channel as any) ?.send({ files: [ new AttachmentBuilder(canvas.toBuffer('image/png'), { @@ -320,8 +320,7 @@ export class Connect4Game { ] }) .then(async (result: Message) => { - boardImageURL = await result.attachments.entries().next().value[1] - .url; + boardImageURL = result.attachments.first()?.url ?? ''; result.delete(); }) diff --git a/apps/bot/src/lib/games/tic-tac-toe.ts b/apps/bot/src/lib/games/tic-tac-toe.ts index ebd43f4e4..f968638e3 100644 --- a/apps/bot/src/lib/games/tic-tac-toe.ts +++ b/apps/bot/src/lib/games/tic-tac-toe.ts @@ -9,8 +9,8 @@ import { ChatInputCommandInteraction, Colors } from 'discord.js'; -import { playersInGame } from '../../commands/other/games'; -import Logger from '../logger'; +import { playersInGame } from '../../commands/other/games.js'; +import Logger from '../logger.js'; export class TicTacToeGame { public async ticTacToe( @@ -32,7 +32,7 @@ export class TicTacToeGame { }); const player1Piece = new Image(); - player1Piece.src = Buffer.from(await player1Image.data); + player1Piece.src = new Uint8Array(await player1Image.data); const player2Avatar = player2!.displayAvatarURL({ extension: 'jpg' @@ -43,7 +43,7 @@ export class TicTacToeGame { url: player2Avatar }); const player2Piece = new Image(); - player2Piece.src = Buffer.from(await player2Image.data); + player2Piece.src = new Uint8Array(await player2Image.data); await game(player1, player2!); async function game(player1: User, player2: User) { let gameBoard: number[][] = [ @@ -81,7 +81,7 @@ export class TicTacToeGame { .setFooter({ text: 'Incase of invisible board click ๐Ÿ”„' }) .setTimestamp(); - await interaction.channel + await (interaction.channel as any) ?.send({ embeds: [Embed] }) .then(async message => { @@ -284,7 +284,7 @@ export class TicTacToeGame { } } - return await interaction.channel + return await (interaction.channel as any) ?.send({ files: [ new AttachmentBuilder(canvas.toBuffer('image/png'), { @@ -294,8 +294,7 @@ export class TicTacToeGame { }) .then(async (result: Message) => { - boardImageURL = await result.attachments.entries().next().value[1] - .url; + boardImageURL = result.attachments.first()?.url ?? ''; await result.delete(); }) diff --git a/apps/bot/src/lib/gifs/searchGif.ts b/apps/bot/src/lib/gifs/searchGif.ts new file mode 100644 index 000000000..b23a1acc1 --- /dev/null +++ b/apps/bot/src/lib/gifs/searchGif.ts @@ -0,0 +1,110 @@ +import { getApiServiceKeys } from '../../env.js'; + +const FALLBACK_GIFS: Record = { + anime: [ + 'https://media.giphy.com/media/13HgwGsXF0aiGY/giphy.gif', + 'https://media.giphy.com/media/oF5oUYTOhvFnO/giphy.gif', + 'https://media.giphy.com/media/v0VvNLK6qnT8c/giphy.gif' + ], + hug: [ + 'https://media.giphy.com/media/od5H3PmEG5EVq/giphy.gif', + 'https://media.giphy.com/media/lrr9rHuoJOE0w/giphy.gif', + 'https://media.giphy.com/media/xJlOdEYy0N55K/giphy.gif' + ], + slap: [ + 'https://media.giphy.com/media/jLeyZWgtwWP2U/giphy.gif', + 'https://media.giphy.com/media/Gf3AUz3eBNbTW/giphy.gif', + 'https://media.giphy.com/media/Zau0yrl15oqdK480Av/giphy.gif' + ], + pat: [ + 'https://media.giphy.com/media/L2z7dnOduqEow/giphy.gif', + 'https://media.giphy.com/media/5tmRHwTlHAA9WkVxTU/giphy.gif', + 'https://media.giphy.com/media/ye7OTQgwmVuNTY22BQ/giphy.gif' + ], + cat: [ + 'https://media.giphy.com/media/JIX9t2j0ZTN9S/giphy.gif', + 'https://media.giphy.com/media/mlvseq9yvZhba/giphy.gif', + 'https://media.giphy.com/media/vFKqnCdLPNOKc/giphy.gif' + ], + doggo: [ + 'https://media.giphy.com/media/mCRJDo24UvJMA/giphy.gif', + 'https://media.giphy.com/media/bbshzgyFQDqPHXBo4c/giphy.gif', + 'https://media.giphy.com/media/4Zo41lhzKt6iZ8xff9/giphy.gif' + ], + baka: [ + 'https://media.giphy.com/media/bOCMPVgsVnRT2/giphy.gif', + 'https://media.giphy.com/media/tO1daDbaecjy0/giphy.gif' + ], + gintama: [ + 'https://media.giphy.com/media/8v6Z3YyUL6GOQ/giphy.gif', + 'https://media.giphy.com/media/Y4gtaaRlLXjLg6MUEg/giphy.gif' + ], + jojo: [ + 'https://media.giphy.com/media/f9jxYYRVPHtKsCf9sy/giphy.gif', + 'https://media.giphy.com/media/TI9HiyUqRm75jDRUUp/giphy.gif' + ], + waifu: [ + 'https://media.giphy.com/media/13HgwGsXF0aiGY/giphy.gif', + 'https://media.giphy.com/media/v0VvNLK6qnT8c/giphy.gif' + ], + amongus: [ + 'https://media.giphy.com/media/RtdRhc7TxBxB0YAsK6/giphy.gif', + 'https://media.giphy.com/media/0dvhnK4yW1H2S0rU1E/giphy.gif' + ], + gif: [ + 'https://media.giphy.com/media/ule4akeEDWA0/giphy.gif', + 'https://media.giphy.com/media/3o7TKSjRrfIPjeiVyM/giphy.gif' + ] +}; + +function getFallbackGif(query: string): string | null { + const key = query.toLowerCase().replace(/[^a-z0-9]/g, ''); + for (const [cat, list] of Object.entries(FALLBACK_GIFS)) { + if (key.includes(cat) || cat.includes(key)) { + return list[Math.floor(Math.random() * list.length)]; + } + } + const general = FALLBACK_GIFS.gif; + return general[Math.floor(Math.random() * general.length)] || null; +} + +export async function searchGif(query: string): Promise { + try { + const apiKey = getApiServiceKeys().klipyApi; + if (!apiKey) { + return getFallbackGif(query); + } + + const response = await fetch( + `https://api.klipy.com/api/v1/${encodeURIComponent( + apiKey + )}/gifs/search?q=${encodeURIComponent(query)}&per_page=20` + ); + + if (!response.ok) { + return getFallbackGif(query); + } + + const json = (await response.json()) as any; + const items = json?.data?.data || json?.data || json?.results || []; + + if (!Array.isArray(items) || items.length === 0) { + return getFallbackGif(query); + } + + // Select a random item from results for variety + const randomItem = items[Math.floor(Math.random() * items.length)]; + + const url = + randomItem?.file?.hd?.gif?.url || + randomItem?.file?.md?.gif?.url || + randomItem?.file?.sm?.gif?.url || + randomItem?.file?.gif?.url || + randomItem?.media_formats?.gif?.url || + randomItem?.url; + + return url || getFallbackGif(query); + } catch { + return getFallbackGif(query); + } +} diff --git a/apps/bot/src/lib/music/buttonHandler.ts b/apps/bot/src/lib/music/buttonHandler.ts index 0c8f2f481..428201f18 100644 --- a/apps/bot/src/lib/music/buttonHandler.ts +++ b/apps/bot/src/lib/music/buttonHandler.ts @@ -1,6 +1,6 @@ -import type { Song } from './classes/Song'; +import type { Song } from './classes/Song.js'; import { container } from '@sapphire/framework'; -import type { Queue } from './classes/Queue'; +import type { Queue } from './classes/Queue.js'; import { Message, ActionRowBuilder, @@ -8,49 +8,104 @@ import { EmbedBuilder, ButtonStyle } from 'discord.js'; -import buttonsCollector, { deletePlayerEmbed } from './buttonsCollector'; +import buttonsCollector, { deletePlayerEmbed } from './buttonsCollector.js'; +import { NowPlayingEmbed } from './nowPlayingEmbed.js'; +import Logger from '../logger.js'; -export async function embedButtons( - embed: EmbedBuilder, - queue: Queue, - song: Song, - message?: string -) { - await deletePlayerEmbed(queue); +export async function getPlayerActionRows( + queue: Queue +): Promise[]> { + const isReplaying = await queue.getReplay(); - const { client } = container; - const tracks = await queue.tracks(); - const row = new ActionRowBuilder().addComponents( + const playbackRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('playPause') - .setLabel('Play/Pause') + .setLabel(queue.paused ? 'โ–ถ๏ธ Resume' : 'โธ๏ธ Pause') + .setStyle(queue.paused ? ButtonStyle.Success : ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId('next') + .setLabel('โญ๏ธ Next') .setStyle(ButtonStyle.Primary), new ButtonBuilder() .setCustomId('stop') - .setLabel('Stop') + .setLabel('โน๏ธ Stop') .setStyle(ButtonStyle.Danger), new ButtonBuilder() - .setCustomId('next') - .setLabel('Next') - .setStyle(ButtonStyle.Primary) - .setDisabled(!tracks.length ? true : false), + .setCustomId('repeat') + .setLabel(isReplaying ? '๐Ÿ” Repeat: ON' : '๐Ÿ” Repeat: OFF') + .setStyle(isReplaying ? ButtonStyle.Success : ButtonStyle.Secondary), new ButtonBuilder() - .setCustomId('volumeUp') - .setLabel('Vol+') - .setStyle(ButtonStyle.Primary), + .setCustomId('shuffle') + .setLabel('๐Ÿ”€ Shuffle') + .setStyle(ButtonStyle.Secondary) + ); + + const volumeRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('volumeDown') - .setLabel('Vol-') - .setStyle(ButtonStyle.Primary) + .setLabel('๐Ÿ”‰ Vol -') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId('volumeUp') + .setLabel('๐Ÿ”Š Vol +') + .setStyle(ButtonStyle.Secondary) ); + return [playbackRow, volumeRow]; +} + +const progressIntervals = new Map(); + +export function stopProgressUpdater(guildId: string) { + const existing = progressIntervals.get(guildId); + if (existing) { + clearInterval(existing); + progressIntervals.delete(guildId); + } +} + +export function startProgressUpdater(queue: Queue) { + stopProgressUpdater(queue.guildID); + + const interval = setInterval(async () => { + try { + if (!queue.player || !queue.player.connected || queue.paused) { + return; + } + const currentTrack = await queue.getCurrentTrack(); + if (!currentTrack) { + stopProgressUpdater(queue.guildID); + return; + } + + await updatePlayerEmbed(queue); + } catch (err) { + // Ignore update errors during transitions + } + }, 5000); + + progressIntervals.set(queue.guildID, interval); +} + +export async function embedButtons( + embed: EmbedBuilder, + queue: Queue, + song: Song, + message?: string +) { + stopProgressUpdater(queue.guildID); + await deletePlayerEmbed(queue); + + const { client } = container; + const rows = await getPlayerActionRows(queue); + const channel = await queue.getTextChannel(); if (!channel) return; return await channel .send({ embeds: [embed], - components: [row], + components: rows, content: message }) .then(async (message: Message) => { @@ -59,6 +114,45 @@ export async function embedButtons( if (queue.player) { await buttonsCollector(message, song); + startProgressUpdater(queue); } }); } + +export async function updatePlayerEmbed(queue: Queue) { + try { + const embedId = await queue.getEmbed(); + if (!embedId) return; + + const channel = await queue.getTextChannel(); + if (!channel) return; + + const currentTrack = await queue.getCurrentTrack(); + if (!currentTrack) return; + + const message = await channel.messages.fetch(embedId).catch(() => null); + if (!message) return; + + const tracks = await queue.tracks(); + const nowPlaying = new NowPlayingEmbed( + currentTrack, + queue.player?.position ?? 0, + currentTrack.length ?? 0, + queue.player?.volume ?? 100, + tracks, + tracks.at(-1), + queue.paused + ); + + const rows = await getPlayerActionRows(queue); + + await message + .edit({ + embeds: [await nowPlaying.NowPlayingEmbed()], + components: rows + }) + .catch(() => {}); + } catch (err) { + Logger.error('Failed to update player embed: ', err); + } +} diff --git a/apps/bot/src/lib/music/buttonsCollector.ts b/apps/bot/src/lib/music/buttonsCollector.ts index 408b7baa5..f2e01df59 100644 --- a/apps/bot/src/lib/music/buttonsCollector.ts +++ b/apps/bot/src/lib/music/buttonsCollector.ts @@ -1,10 +1,11 @@ import { Time } from '@sapphire/time-utilities'; import type { Message, MessageComponentInteraction } from 'discord.js'; import { container } from '@sapphire/framework'; -import type { Queue } from './classes/Queue'; -import { NowPlayingEmbed } from './nowPlayingEmbed'; -import type { Song } from './classes/Song'; -import Logger from '../logger'; +import type { Queue } from './classes/Queue.js'; +import { NowPlayingEmbed } from './nowPlayingEmbed.js'; +import type { Song } from './classes/Song.js'; +import Logger from '../logger.js'; +import { getPlayerActionRows, stopProgressUpdater } from './buttonHandler.js'; export default async function buttonsCollector(message: Message, song: Song) { const { client } = container; @@ -42,29 +43,80 @@ export default async function buttonsCollector(message: Message, song: Song) { const tracks = await queue.tracks(); const NowPlaying = new NowPlayingEmbed( song, - queue.player.accuratePosition, - queue.player.trackData?.length ?? 0, - queue.player.volume, + queue.player?.position ?? 0, + song.length, + queue.player?.volume ?? 100, tracks, tracks.at(-1), - queue.player.paused + queue.paused ); + const rows = await getPlayerActionRows(queue); collector.empty(); - await i.update({ - embeds: [await NowPlaying.NowPlayingEmbed()] - }); + await i + .update({ + embeds: [await NowPlaying.NowPlayingEmbed()], + components: rows + }) + .catch(() => {}); return; } if (i.customId === 'stop') { + await i.deferUpdate().catch(() => {}); clearTimeout(timer); await queue.leave(); return; } if (i.customId === 'next') { + await i.deferUpdate().catch(() => {}); clearTimeout(timer); await queue.next({ skipped: true }); return; } + if (i.customId === 'repeat') { + const currentReplay = await queue.getReplay(); + await queue.setReplay(!currentReplay); + const tracks = await queue.tracks(); + const NowPlaying = new NowPlayingEmbed( + song, + queue.player?.position ?? 0, + song.length, + queue.player?.volume ?? 100, + tracks, + tracks.at(-1), + queue.paused + ); + const rows = await getPlayerActionRows(queue); + collector.empty(); + await i + .update({ + embeds: [await NowPlaying.NowPlayingEmbed()], + components: rows + }) + .catch(() => {}); + return; + } + if (i.customId === 'shuffle') { + await queue.shuffleTracks(); + const tracks = await queue.tracks(); + const NowPlaying = new NowPlayingEmbed( + song, + queue.player?.position ?? 0, + song.length, + queue.player?.volume ?? 100, + tracks, + tracks.at(-1), + queue.paused + ); + const rows = await getPlayerActionRows(queue); + collector.empty(); + await i + .update({ + embeds: [await NowPlaying.NowPlayingEmbed()], + components: rows + }) + .catch(() => {}); + return; + } if (i.customId === 'volumeUp') { const currentVolume = await queue.getVolume(); const volume = currentVolume + 10 > 200 ? 200 : currentVolume + 10; @@ -72,17 +124,21 @@ export default async function buttonsCollector(message: Message, song: Song) { const tracks = await queue.tracks(); const NowPlaying = new NowPlayingEmbed( song, - queue.player.accuratePosition, - queue.player.trackData?.length ?? 0, - queue.player.volume, + queue.player?.position ?? 0, + song.length, + queue.player?.volume ?? 100, tracks, tracks.at(-1), - queue.player.paused + queue.paused ); + const rows = await getPlayerActionRows(queue); collector.empty(); - await i.update({ - embeds: [await NowPlaying.NowPlayingEmbed()] - }); + await i + .update({ + embeds: [await NowPlaying.NowPlayingEmbed()], + components: rows + }) + .catch(() => {}); return; } if (i.customId === 'volumeDown') { @@ -92,15 +148,21 @@ export default async function buttonsCollector(message: Message, song: Song) { const tracks = await queue.tracks(); const NowPlaying = new NowPlayingEmbed( song, - queue.player.accuratePosition, - queue.player.trackData?.length ?? 0, - queue.player.volume, + queue.player?.position ?? 0, + song.length, + queue.player?.volume ?? 100, tracks, tracks.at(-1), - queue.player.paused + queue.paused ); + const rows = await getPlayerActionRows(queue); collector.empty(); - await i.update({ embeds: [await NowPlaying.NowPlayingEmbed()] }); + await i + .update({ + embeds: [await NowPlaying.NowPlayingEmbed()], + components: rows + }) + .catch(() => {}); return; } }); @@ -114,18 +176,23 @@ export default async function buttonsCollector(message: Message, song: Song) { export async function deletePlayerEmbed(queue: Queue) { try { + stopProgressUpdater(queue.guildID); const embedID = await queue.getEmbed(); if (embedID) { const channel = await queue.getTextChannel(); - await channel?.messages.fetch(embedID).then(async oldMessage => { - if (oldMessage) - await oldMessage.delete().catch(error => { - Logger.error('Failed to Delete Old Message. ' + error); - }); - await queue.deleteEmbed(); - }); + if (channel) { + try { + const oldMessage = await channel.messages.fetch(embedID); + if (oldMessage && oldMessage.deletable) { + await oldMessage.delete(); + } + } catch { + // Message already deleted by user or channel purged + } + } + await queue.deleteEmbed(); } } catch (error) { - Logger.error('Failed to Delete Player Embed. ' + error); + Logger.error('Failed to Delete Player Embed: ', error); } } diff --git a/apps/bot/src/lib/music/channelHandler.ts b/apps/bot/src/lib/music/channelHandler.ts index 4439fd530..e17e58e1a 100644 --- a/apps/bot/src/lib/music/channelHandler.ts +++ b/apps/bot/src/lib/music/channelHandler.ts @@ -1,6 +1,6 @@ -import type { Queue } from './classes/Queue'; +import type { Queue } from './classes/Queue.js'; import { Channel, GuildMember, ChannelType } from 'discord.js'; -import Logger from '../logger'; +import Logger from '../logger.js'; export async function manageStageChannel( voiceChannel: Channel, @@ -10,9 +10,12 @@ export async function manageStageChannel( if (voiceChannel.type !== ChannelType.GuildStageVoice) return; // Stage Channel Permissions From Discord.js Doc's if ( - !botUser?.permissions.has( - ('ManageChannels' && 'MuteMembers' && 'MoveMembers') || 'ADMINISTRATOR' - ) + !botUser?.permissions.has([ + 'ManageChannels', + 'MuteMembers', + 'MoveMembers' + ]) && + !botUser?.permissions.has('Administrator') ) if (botUser.voice.suppress) return await instance.getTextChannel().then( @@ -22,13 +25,11 @@ export async function manageStageChannel( }) ); const tracks = await instance.tracks(); + const currentTitle = tracks.at(0)?.title ?? ''; const title = - instance.player.trackData?.title.length! > 114 - ? `๐ŸŽถ ${ - instance.player.trackData?.title.slice(0, 114) ?? - tracks.at(0)?.title.slice(0, 114) - }...` - : `๐ŸŽถ ${instance.player.trackData?.title ?? tracks.at(0)?.title ?? ''}`; + currentTitle.length > 114 + ? `๐ŸŽถ ${currentTitle.slice(0, 114)}...` + : `๐ŸŽถ ${currentTitle}`; if (!voiceChannel.stageInstance) { await voiceChannel diff --git a/apps/bot/src/lib/music/classes/Queue.ts b/apps/bot/src/lib/music/classes/Queue.ts index 95eb498a4..f68d43f79 100644 --- a/apps/bot/src/lib/music/classes/Queue.ts +++ b/apps/bot/src/lib/music/classes/Queue.ts @@ -1,4 +1,4 @@ -// Inspired from skyra's queue(when it had a music feature) +// In-Memory Queue Store (Zero External Redis Dependency) import type { CommandInteraction, Guild, @@ -6,16 +6,13 @@ import type { TextChannel, VoiceChannel } from 'discord.js'; -import type { Song } from './Song'; -import type { Track } from '@lavaclient/types/v3'; -import type { DiscordResource, Player, Snowflake } from 'lavaclient'; +import type { Song } from './Song.js'; +import type { Player } from 'lavalink-client'; import { container } from '@sapphire/framework'; -import type { QueueStore } from './QueueStore'; -import { Time } from '@sapphire/time-utilities'; -import { isNullish } from '@sapphire/utilities'; -import { deletePlayerEmbed } from '../buttonsCollector'; -import { trpcNode } from '../../../trpc'; -import Logger from '../../logger'; +import type { QueueStore } from './QueueStore.js'; +import { deletePlayerEmbed } from '../buttonsCollector.js'; +import { dataService } from '../../../dataService.js'; +import Logger from '../../logger.js'; export enum LoopType { None, @@ -23,8 +20,6 @@ export enum LoopType { Song } -const kExpireTime = Time.Day * 2; - export interface QueueEvents { trackStart: (song: Song) => void; trackEnd: (song: Song) => void; @@ -38,68 +33,55 @@ export interface Loop { } export interface AddOptions { - requester?: Snowflake | DiscordResource; + requester?: string; userInfo?: GuildMember; added?: number; next?: boolean; } -export type Addable = string | Track | Song; +export type Addable = string | Song; -interface NowPlaying { +export interface NowPlaying { song: Song; position: number; } -interface QueueKeys { - readonly next: string; - readonly position: string; - readonly current: string; - readonly skips: string; - readonly systemPause: string; - readonly replay: string; - readonly volume: string; - readonly text: string; - readonly embed: string; -} - export class Queue { - public readonly keys: QueueKeys; - private skipped: boolean; + public skipped = false; + private _tracks: Song[] = []; + private _current: Song | null = null; + private _replay = false; + private _systemPaused = false; + private _volume = 100; + private _textChannelId: string | null = null; + private _embedId: string | null = null; public constructor( public readonly store: QueueStore, public readonly guildID: string - ) { - this.keys = { - current: `music.${this.guildID}.current`, - next: `music.${this.guildID}.next`, - position: `music.${this.guildID}.position`, - skips: `music.${this.guildID}.skips`, - systemPause: `music.${this.guildID}.systemPause`, - replay: `music.${this.guildID}.replay`, - volume: `music.${this.guildID}.volume`, - text: `music.${this.guildID}.text`, - embed: `music.${this.guildID}.embed` - }; - - this.skipped = false; - } + ) {} public get client() { return container.client; } public get player(): Player { - return this.store.client.players.get(this.guildID)!; + return this.store.client.getPlayer(this.guildID)!; } public get playing(): boolean { - return this.player.playing; + return Boolean( + this.player?.playing || + (this.player?.voiceChannelId && this.player?.connected) + ); + } + + public async isPlaying(): Promise { + return Boolean(this._current); } public get paused(): boolean { - return this.player.paused; + return Boolean(this.player?.paused); } public get guild(): Guild { @@ -109,46 +91,70 @@ export class Queue { public get voiceChannel(): VoiceChannel | null { const id = this.voiceChannelID; return id - ? (this.guild.channels.cache.get(id) as VoiceChannel) ?? null + ? ((this.guild?.channels.cache.get(id) as VoiceChannel) ?? null) : null; } public get voiceChannelID(): string | null { if (!this.player) return null; - return this.player.channelId ?? null; + return this.player.voiceChannelId ?? null; } - public createPlayer(): Player { + public createPlayer(voiceChannelId?: string): Player { let player = this.player; if (!player) { - player = this.store.client.createPlayer(this.guildID); - player.on('trackEnd', async () => { - if (!this.skipped) { - await this.next(); - } - this.skipped = false; + player = this.store.client.createPlayer({ + guildId: this.guildID, + voiceChannelId: voiceChannelId || '', + selfDeaf: true }); + } else if (voiceChannelId) { + player.options.voiceChannelId = voiceChannelId; + player.voiceChannelId = voiceChannelId; } return player; } - public destroyPlayer(): void { + public async destroyPlayer(): Promise { if (this.player) { - this.store.client.destroyPlayer(this.guildID); + await this.player.destroy(); } } - // Start the queue public async start(replaying = false): Promise { const np = await this.nowPlaying(); if (!np) return this.next(); + const player = this.player || this.createPlayer(); + if (!player) { + Logger.error( + `Could not retrieve or create Lavalink player for guild ${this.guildID}` + ); + return false; + } + try { - this.player.setVolume(await this.getVolume()); - await this.player.play(np.song as Song); + const volume = await this.getVolume(); + await player.setVolume(volume); + const trackString = (np.song as Song).track; + await player.node.updatePlayer({ + guildId: this.guildID, + noReplace: false, + playerOptions: { + track: { + encoded: trackString + }, + volume, + position: 0, + paused: false + } + }); + player.playing = true; + player.paused = false; } catch (err) { - Logger.error(err); + Logger.error('Failed to start track on Lavalink: ', err); await this.leave(); + return false; } this.client.emit( @@ -159,31 +165,27 @@ export class Queue { return true; } - // Returns whether or not there are songs that can be played public async canStart(): Promise { - return ( - (await this.store.redis.exists(this.keys.current, this.keys.next)) > 0 - ); + return Boolean(this._current || this._tracks.length > 0); } - // add tracks to queue public async add( songs: Song | Array, options: AddOptions = {} ): Promise { - songs = Array.isArray(songs) ? songs : [songs]; - if (!songs.length) return 0; + const list = Array.isArray(songs) ? songs : [songs]; + if (!list.length) return 0; - await this.store.redis.lpush( - this.keys.next, - ...songs.map(song => this.stringifySong(song)) - ); - await this.refresh(); - return songs.length; + if (options.next) { + this._tracks.unshift(...list); + } else { + this._tracks.push(...list); + } + return list.length; } public async pause(interaction?: CommandInteraction) { - await this.player.pause(true); + if (this.player) await this.player.pause(); await this.setSystemPaused(false); if (interaction) { this.client.emit('musicSongPause', interaction); @@ -191,7 +193,7 @@ export class Queue { } public async resume(interaction?: CommandInteraction) { - await this.player.pause(false); + if (this.player) await this.player.resume(); await this.setSystemPaused(false); if (interaction) { this.client.emit('musicSongResume', interaction); @@ -199,95 +201,68 @@ export class Queue { } public async getSystemPaused(): Promise { - return await this.store.redis - .get(this.keys.systemPause) - .then(d => d === '1'); + return this._systemPaused; } public async setSystemPaused(value: boolean): Promise { - await this.store.redis.set(this.keys.systemPause, value ? '1' : '0'); - await this.refresh(); + this._systemPaused = value; return value; } - /** - * Retrieves whether or not the system should repeat the current track. - */ public async getReplay(): Promise { - return await this.store.redis.get(this.keys.replay).then(d => d === '1'); + return this._replay; } public async setReplay(value: boolean): Promise { - await this.store.redis.set(this.keys.replay, value ? '1' : '0'); - await this.refresh(); + this._replay = value; this.client.emit('musicReplayUpdate', this, value); return value; } - /** - * Retrieves the volume of the track in the queue. - */ - public async getVolume(): Promise { - let data = await this.store.redis.get(this.keys.volume); - - if (!data) { - const guildQuery = await trpcNode.guild.getGuild.query({ - id: this.guildID - }); - - if (!guildQuery || !guildQuery.guild) - await this.setVolume(this.player.volume ?? 100); // saves to both - - if (guildQuery.guild) - data = - guildQuery.guild.volume.toString() || this.player.volume.toString(); - } - - return data ? Number(data) : 100; + return this._volume; } - // set the volume of the track in the queue public async setVolume( value: number ): Promise<{ previous: number; next: number }> { - await this.player.setVolume(value); - const previous = await this.store.redis.getset(this.keys.volume, value); - await this.refresh(); + const previous = this._volume; + this._volume = value; + if (this.player) await this.player.setVolume(value); - await trpcNode.guild.updateVolume.mutate({ - guildId: this.guildID, - volume: this.player.volume - }); + await dataService.guild.updateVolume({ + guildId: this.guildID, + volume: value + }) + .catch(() => {}); this.client.emit('musicSongVolumeUpdate', this, value); - return { - previous: previous === null ? 100 : Number(previous), - next: value - }; + return { previous, next: value }; } public async seek(position: number): Promise { - await this.player.seek(position); + if (this.player) await this.player.seek(position); } - // connect to a voice channel public async connect(channelID: string): Promise { - await this.player.connect(channelID, { deafened: true }); + const player = this.createPlayer(channelID); + player.options.voiceChannelId = channelID; + player.voiceChannelId = channelID; + await player.connect(); } - // leave the voice channel public async leave(): Promise { if (await this.getEmbed()) { await deletePlayerEmbed(this); } if (this.client.leaveTimers[this.guildID]) { - clearTimeout(this.client.leaveTimers[this.player.guildId]); - delete this.client.leaveTimers[this.player.guildId]; + clearTimeout(this.client.leaveTimers[this.guildID]); + delete this.client.leaveTimers[this.guildID]; + } + if (this.player) { + await this.player.disconnect(); + await this.destroyPlayer(); } - if (!this.player) return; - await this.player.disconnect(); - await this.destroyPlayer(); await this.setTextChannelID(null); await this.clear(); } @@ -296,7 +271,7 @@ export class Queue { const id = await this.getTextChannelID(); if (id === null) return null; - const channel = this.guild.channels.cache.get(id) ?? null; + const channel = this.guild?.channels.cache.get(id) ?? null; if (channel === null) { await this.setTextChannelID(null); return null; @@ -305,160 +280,125 @@ export class Queue { return channel as TextChannel; } - public getTextChannelID(): Promise { - return this.store.redis.get(this.keys.text); + public async getTextChannelID(): Promise { + return this._textChannelId; } - public setTextChannelID(channelID: null): Promise; - - public async setTextChannelID(channelID: string): Promise; public async setTextChannelID( channelID: string | null ): Promise { - if (channelID === null) { - await this.store.redis.del(this.keys.text); - } else { - await this.store.redis.set(this.keys.text, channelID); - await this.refresh(); - } - + this._textChannelId = channelID; return channelID; } public async getCurrentTrack(): Promise { - const value = await this.store.redis.get(this.keys.current); - return value ? this.parseSongString(value) : null; + return this._current; } public async getAt(index: number): Promise { - const value = await this.store.redis.lindex(this.keys.next, -index - 1); - return value ? this.parseSongString(value) : undefined; + return this._tracks[index]; } public async removeAt(position: number): Promise { - await this.store.redis.lremat(this.keys.next, -position - 1); - await this.refresh(); + if (position >= 0 && position < this._tracks.length) { + this._tracks.splice(position, 1); + } } public async next({ skipped = false } = {}): Promise { if (skipped) this.skipped = true; - // Sets the current position to 0. - await this.store.redis.del(this.keys.position); - - // Get whether or not the queue is on replay mode. - const replaying = await this.getReplay(); + const replaying = this._replay; - // If not skipped (song ended) and is replaying, replay. - if (!skipped && replaying) { + if (!skipped && replaying && this._current) { return await this.start(true); } - // If it was skipped, set replay back to false. - if (replaying) await this.setReplay(false); + if (replaying) this._replay = false; - // Removes the next entry from the list and sets it as the current track. - const entry = await this.store.redis.rpopset( - this.keys.next, - this.keys.current - ); - // If there was an entry to play, refresh the state and start playing. - if (entry) { - await this.refresh(); + const nextEntry = this._tracks.shift() ?? null; + this._current = nextEntry; + + if (nextEntry) { return this.start(false); } else { - // If there was no entry, disconnect from the voice channel. await this.leave(); this.client.emit('musicFinish', this, true); return false; } } - public count(): Promise { - return this.store.redis.llen(this.keys.next); + public async count(): Promise { + return this._tracks.length; } public async moveTracks(from: number, to: number): Promise { - await this.store.redis.lmove(this.keys.next, -from - 1, -to - 1); // work from the end of the list, since it's reversed - await this.refresh(); + if (from >= 0 && from < this._tracks.length && to >= 0 && to < this._tracks.length) { + const [item] = this._tracks.splice(from, 1); + if (item) this._tracks.splice(to, 0, item); + } } public async shuffleTracks(): Promise { - await this.store.redis.lshuffle(this.keys.next, Date.now()); - await this.refresh(); + for (let i = this._tracks.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [this._tracks[i], this._tracks[j]] = [this._tracks[j], this._tracks[i]]; + } } public async stop(): Promise { - await this.player.stop(); + await this.destroyPlayer(); } public async clearTracks(): Promise { - await this.store.redis.del(this.keys.next); + this._tracks = []; } public async skipTo(position: number): Promise { - await this.store.redis.ltrim(this.keys.next, 0, position - 1); + if (position > 0 && position < this._tracks.length) { + this._tracks.splice(0, position); + } await this.next({ skipped: true }); } - public refresh() { - return this.store.redis - .pipeline() - .pexpire(this.keys.next, kExpireTime) - .pexpire(this.keys.position, kExpireTime) - .pexpire(this.keys.current, kExpireTime) - .pexpire(this.keys.skips, kExpireTime) - .pexpire(this.keys.systemPause, kExpireTime) - .pexpire(this.keys.replay, kExpireTime) - .pexpire(this.keys.volume, kExpireTime) - .pexpire(this.keys.text, kExpireTime) - .pexpire(this.keys.embed, kExpireTime) - .exec(); - } - - public clear(): Promise { - return this.store.redis.del( - this.keys.next, - this.keys.position, - this.keys.current, - this.keys.skips, - this.keys.systemPause, - this.keys.replay, - this.keys.volume, - this.keys.text, - this.keys.embed - ); + public async refresh(): Promise { + // In-memory state does not expire } - public async nowPlaying(): Promise { - const [entry, position] = await Promise.all([ - this.getCurrentTrack(), - this.store.redis.get(this.keys.position) - ]); - if (entry === null) return null; + public async clear(): Promise { + const count = this._tracks.length; + this._tracks = []; + this._current = null; + this._replay = false; + this._systemPaused = false; + this._embedId = null; + return count; + } + public async nowPlaying(): Promise { + if (!this._current) return null; return { - song: entry, - position: isNullish(position) ? 0 : parseInt(position, 10) + song: this._current, + position: this.player?.position ?? 0 }; } public async tracks(start = 0, end = -1): Promise { - if (end === Infinity) end = -1; - - const tracks = await this.store.redis.lrange(this.keys.next, start, end); - return [...tracks].map(this.parseSongString).reverse(); + if (end === -1 || end === Infinity) { + return this._tracks.slice(start); + } + return this._tracks.slice(start, end + 1); } public async setEmbed(id: string): Promise { - await this.store.redis.set(this.keys.embed, id); + this._embedId = id; } public async getEmbed(): Promise { - return this.store.redis.get(this.keys.embed); + return this._embedId; } public async deleteEmbed(): Promise { - await this.store.redis.del(this.keys.embed); + this._embedId = null; } public stringifySong(song: Song): string { @@ -466,6 +406,6 @@ export class Queue { } public parseSongString(song: string): Song { - return JSON.parse(song); + return typeof song === 'string' ? JSON.parse(song) : song; } } diff --git a/apps/bot/src/lib/music/classes/QueueClient.ts b/apps/bot/src/lib/music/classes/QueueClient.ts index 55191d846..b655ca6fd 100644 --- a/apps/bot/src/lib/music/classes/QueueClient.ts +++ b/apps/bot/src/lib/music/classes/QueueClient.ts @@ -1,30 +1,49 @@ -import Redis from 'ioredis'; -import type { RedisOptions } from 'ioredis'; -import { ConnectionInfo, Node, SendGatewayPayload } from 'lavaclient'; -import { QueueStore } from './QueueStore'; +import { LavalinkManager, LavalinkNodeOptions } from 'lavalink-client'; +import { QueueStore } from './QueueStore.js'; +import { container } from '@sapphire/framework'; export interface QueueClientOptions { - redis: Redis | RedisOptions; + node: LavalinkNodeOptions; + clientId?: string; } -export interface ConstructorTypes { - options: QueueClientOptions; - sendGatewayPayload: SendGatewayPayload; - connection: ConnectionInfo; -} - -export class QueueClient extends Node { +export class QueueClient extends LavalinkManager { public readonly queues: QueueStore; - public constructor({ - options, - sendGatewayPayload, - connection - }: ConstructorTypes) { - super({ ...options, sendGatewayPayload, connection }); - this.queues = new QueueStore( - this, - options.redis instanceof Redis ? options.redis : new Redis(options.redis) - ); + public constructor(options: QueueClientOptions) { + super({ + nodes: [options.node], + sendToShard: (guildId, payload) => { + container.client.guilds.cache.get(guildId)?.shard?.send(payload); + }, + client: { + id: options.clientId || process.env.DISCORD_CLIENT_ID || '', + username: 'Master-Bot' + } + }); + + this.queues = new QueueStore(this); + + const patchNode = (node: any) => { + const originalUpdatePlayer = node.updatePlayer.bind(node); + node.updatePlayer = async (data: any) => { + if (data?.playerOptions?.voice && !data.playerOptions.voice.channelId) { + const player = this.getPlayer(data.guildId); + data.playerOptions.voice.channelId = + player?.voiceChannelId || player?.options?.voiceChannelId || ''; + } + return originalUpdatePlayer(data); + }; + }; + + for (const node of this.nodeManager.nodes.values()) { + patchNode(node); + } + this.nodeManager.on('create', node => patchNode(node)); + } + + public override destroyPlayer(guildId: string, destroyReason?: string) { + return super.destroyPlayer(guildId, destroyReason); } } + diff --git a/apps/bot/src/lib/music/classes/QueueStore.ts b/apps/bot/src/lib/music/classes/QueueStore.ts index 2d00adcb8..7a8762b55 100644 --- a/apps/bot/src/lib/music/classes/QueueStore.ts +++ b/apps/bot/src/lib/music/classes/QueueStore.ts @@ -1,72 +1,13 @@ import { Collection } from 'discord.js'; -import { readFileSync } from 'fs'; -import type { Redis, RedisKey } from 'ioredis'; -import { join, resolve } from 'path'; -import { Queue } from './Queue'; -import type { QueueClient } from './QueueClient'; -import Logger from '../../logger'; - -interface RedisCommand { - name: string; - keys: number; -} - -const commands: RedisCommand[] = [ - { - name: 'lmove', - keys: 1 - }, - { - name: 'lremat', - keys: 1 - }, - { - name: 'lshuffle', - keys: 1 - }, - { - name: 'rpopset', - keys: 2 - } -]; - -//@ts-ignore -export interface ExtendedRedis extends Redis { - lmove: (key: RedisKey, from: number, to: number) => Promise<'OK'>; - lremat: (key: RedisKey, index: number) => Promise<'OK'>; - lshuffle: (key: RedisKey, seed: number) => Promise<'OK'>; - rpopset: (source: RedisKey, destination: RedisKey) => Promise; -} +import { Queue } from './Queue.js'; +import type { QueueClient } from './QueueClient.js'; export class QueueStore extends Collection { - public redis: ExtendedRedis; - - public constructor( - public readonly client: QueueClient, - redis: Redis - ) { + public constructor(public readonly client: QueueClient) { super(); - this.redis = redis as any; - // Redis Errors - redis.on('error', err => { - Logger.error('Redis ' + err); - }); - - for (const command of commands) { - this.redis.defineCommand(command.name, { - numberOfKeys: command.keys, - lua: readFileSync( - resolve( - join(__dirname, '..', '..', '..'), - 'audio', - `${command.name}.lua` - ) - ).toString() - }); - } } - public get(key: string): Queue { + public override get(key: string): Queue { let queue = super.get(key); if (!queue) { queue = new Queue(this, key); @@ -76,32 +17,7 @@ export class QueueStore extends Collection { } public async start() { - const guilds = await this.getPlayingEntries(); - await Promise.all(guilds.map(guild => this.get(guild).start())); - } - - private async getPlayingEntries(): Promise { - const guilds = new Set(); - - let cursor = '0'; - do { - // `scan` returns a tuple with the next cursor (which must be used for the - // next iteration) and an array of the matching keys. The iterations end when - // cursor becomes '0' again. - const response = await this.redis.scan( - cursor, - 'MATCH', - 'music.*.position' - ); - [cursor] = response; - - for (const key of response[1]) { - // Slice 'skyra.a.' from the start, and '.p' from the end: - const id = key.slice(8, -2); - guilds.add(id); - } - } while (cursor !== '0'); - - return [...guilds]; + await Promise.all(this.map(queue => queue.start())); } } + diff --git a/apps/bot/src/lib/music/classes/Song.ts b/apps/bot/src/lib/music/classes/Song.ts index e0d2103d3..147080050 100644 --- a/apps/bot/src/lib/music/classes/Song.ts +++ b/apps/bot/src/lib/music/classes/Song.ts @@ -1,7 +1,21 @@ import { decode } from '@lavalink/encoding'; -import type { Track, TrackInfo } from '@lavaclient/types/v3'; import * as MetadataFilter from 'metadata-filter'; +export interface TrackInfo { + track: string; + length: number; + identifier: string; + author: string; + isStream: boolean; + position: number; + title: string; + uri: string; + isSeekable: boolean; + sourceName: string; + thumbnail: string; + added: number; +} + export class Song implements TrackInfo { readonly track: string; requester?: RequesterInfo; @@ -17,12 +31,7 @@ export class Song implements TrackInfo { thumbnail: string; added: number; - constructor( - track: string | Track, - added?: number, - requester?: RequesterInfo - ) { - this.track = typeof track === 'string' ? track : track.track; + constructor(track: string | any, added?: number, requester?: RequesterInfo) { this.requester = requester; this.added = added ?? Date.now(); const filterSet = { @@ -37,54 +46,58 @@ export class Song implements TrackInfo { }; const filter = MetadataFilter.createFilter(filterSet); - // TODO: make this less shitty if (typeof track !== 'string') { - this.length = track.info.length; - this.identifier = track.info.identifier; - this.author = track.info.author; - this.isStream = track.info.isStream; - this.position = track.info.position; - this.title = filter.filterField('song', track.info.title); - this.uri = track.info.uri; - this.isSeekable = track.info.isSeekable; - this.sourceName = track.info.sourceName; + this.track = track.encoded ?? track.track ?? ''; + this.length = Number( + track.info?.duration ?? + track.info?.length ?? + track.duration ?? + track.length ?? + 0 + ); + this.identifier = track.info?.identifier ?? track.identifier ?? ''; + this.author = track.info?.author ?? track.author ?? ''; + this.isStream = Boolean(track.info?.isStream ?? track.isStream ?? false); + this.position = Number(track.info?.position ?? track.position ?? 0); + this.title = filter.filterField( + 'song', + track.info?.title ?? track.title ?? '' + ); + this.uri = track.info?.uri ?? track.uri ?? ''; + this.isSeekable = Boolean( + track.info?.isSeekable ?? track.isSeekable ?? !this.isStream + ); + this.sourceName = track.info?.sourceName ?? track.sourceName ?? 'youtube'; + this.thumbnail = + track.info?.artworkUrl || + track.artworkUrl || + this.getThumbnailFallback(); } else { + this.track = track; const decoded = decode(this.track); - this.length = Number(decoded.length); + this.length = Number(decoded.length || (decoded as any).duration || 0); this.identifier = decoded.identifier; this.author = decoded.author; - this.isStream = decoded.isStream; - this.position = Number(decoded.position); + this.isStream = Boolean(decoded.isStream); + this.position = Number(decoded.position || 0); this.title = filter.filterField('song', decoded.title); this.uri = decoded.uri!; this.isSeekable = !decoded.isStream; this.sourceName = decoded.source; + this.thumbnail = this.getThumbnailFallback(); } - // Thumbnails - switch (this.sourceName) { - case 'soundcloud': { - this.thumbnail = - 'https://a-v2.sndcdn.com/assets/images/sc-icons/fluid-b4e7a64b8b.png'; // SoundCloud Logo - break; - } - case 'vimeo': { - this.thumbnail = 'https://i.imgur.com/npxyTWi.png'; // Vimeo Logo - break; - } - - case 'youtube': { - this.thumbnail = `https://img.youtube.com/vi/${this.identifier}/hqdefault.jpg`; // Track Thumbnail - break; - } - case 'twitch': { - this.thumbnail = 'https://i.imgur.com/nO3f4jq.png'; // large Twitch Logo - break; - } + } - default: { - this.thumbnail = 'https://cdn.discordapp.com/embed/avatars/1.png'; // Discord Default Avatar - break; - } + private getThumbnailFallback(): string { + switch (this.sourceName) { + case 'vimeo': + return 'https://i.imgur.com/npxyTWi.png'; + case 'youtube': + return `https://img.youtube.com/vi/${this.identifier}/hqdefault.jpg`; + case 'twitch': + return 'https://i.imgur.com/nO3f4jq.png'; + default: + return 'https://cdn.discordapp.com/embed/avatars/1.png'; } } } diff --git a/apps/bot/src/lib/music/classes/TriviaSession.ts b/apps/bot/src/lib/music/classes/TriviaSession.ts new file mode 100644 index 000000000..b68783c64 --- /dev/null +++ b/apps/bot/src/lib/music/classes/TriviaSession.ts @@ -0,0 +1,345 @@ +import { + EmbedBuilder, + type Message, + type MessageCollector, + type TextChannel +} from 'discord.js'; +import { container } from '@sapphire/framework'; +import { checkMatch } from '../triviaMatcher.js'; +import { TRIVIA_SONGS, type TriviaSong } from '../triviaSongs.js'; +import Logger from '../../logger.js'; +import type { Player } from 'lavalink-client'; + +export interface ParticipantScore { + userId: string; + username: string; + points: number; +} + +export class TriviaSession { + public readonly guildId: string; + public readonly textChannel: TextChannel; + public readonly voiceChannelId: string; + public readonly totalRounds: number; + public readonly songs: TriviaSong[]; + + public currentRound: number = 0; + public scores: Map = new Map(); + public currentSong: TriviaSong | null = null; + public titleGuessedBy: string | null = null; + public artistGuessedBy: string | null = null; + + public isEnded: boolean = false; + private roundTimer: NodeJS.Timeout | null = null; + private messageCollector: MessageCollector | null = null; + + public constructor( + guildId: string, + textChannel: TextChannel, + voiceChannelId: string, + rounds = 5, + category?: string + ) { + this.guildId = guildId; + this.textChannel = textChannel; + this.voiceChannelId = voiceChannelId; + this.totalRounds = Math.min(Math.max(rounds, 1), 15); + + let pool = TRIVIA_SONGS; + if (category && category !== 'all') { + const filtered = TRIVIA_SONGS.filter(s => s.category === category); + if (filtered.length > 0) pool = filtered; + } + + this.songs = [...pool] + .sort(() => 0.5 - Math.random()) + .slice(0, this.totalRounds); + } + + private get client() { + return container.client; + } + + private get player(): Player | null { + return this.client.music.getPlayer(this.guildId) || null; + } + + public async start(): Promise { + let player = this.player; + if (!player) { + player = this.client.music.createPlayer({ + guildId: this.guildId, + voiceChannelId: this.voiceChannelId, + selfDeaf: true + }); + } else { + player.options.voiceChannelId = this.voiceChannelId; + player.voiceChannelId = this.voiceChannelId; + } + + await player.connect(); + + const startEmbed = new EmbedBuilder() + .setTitle('๐ŸŽต Music Trivia Game Starting!') + .setColor('Gold') + .setDescription( + `**Get ready!** We will play **${this.songs.length}** songs.\n` + + `Guess the **Song Title** or the **Artist** in this text channel.\n\n` + + `โ€ข **+1 Point** for Song Title\n` + + `โ€ข **+1 Point** for Artist\n` + + `โ€ข **30 Seconds** per song\n\n` + + `*Starting round 1 in 3 seconds...*` + ) + .setTimestamp(); + + await this.textChannel.send({ embeds: [startEmbed] }); + + setTimeout(() => { + if (!this.isEnded) { + void this.nextRound(); + } + }, 3000); + } + + public async nextRound(): Promise { + if (this.currentRound >= this.songs.length || this.isEnded) { + return this.endGame(); + } + + this.currentSong = this.songs[this.currentRound]; + this.currentRound++; + this.titleGuessedBy = null; + this.artistGuessedBy = null; + + const node = this.client.music.nodeManager.nodes.values().next().value; + if (!node) { + await this.textChannel.send(':x: Audio engine unavailable for trivia.'); + return this.endGame(); + } + + try { + const res = await node.search( + { query: this.currentSong.query }, + { id: this.client.user?.id || 'bot', name: 'Trivia' } + ); + + const track = res?.tracks?.[0]; + if (!track) { + Logger.warn(`Trivia song not found: ${this.currentSong.query}`); + return this.nextRound(); + } + + const player = this.player; + if (player) { + const encodedTrack = track.encoded; + await player.node.updatePlayer({ + guildId: this.guildId, + noReplace: false, + playerOptions: { + track: { + encoded: encodedTrack + }, + position: 0, + paused: false + } + }); + player.playing = true; + player.paused = false; + } + + const roundEmbed = new EmbedBuilder() + .setTitle(`๐ŸŽต Round ${this.currentRound} / ${this.songs.length}`) + .setColor('Blue') + .setDescription( + '๐ŸŽง **Listen to the clip!** Type your guesses for **Title** and **Artist** in this channel!\n*(30 seconds on the clock)*' + ) + .setFooter({ text: 'Type your guess directly in chat!' }); + + await this.textChannel.send({ embeds: [roundEmbed] }); + + this.startCollector(); + + this.roundTimer = setTimeout(() => { + void this.finishRound(); + }, 30000); + } catch (err) { + Logger.error('Error starting trivia round: ', err); + void this.nextRound(); + } + } + + private startCollector(): void { + if (this.messageCollector) { + this.messageCollector.stop(); + } + + this.messageCollector = this.textChannel.createMessageCollector({ + filter: (m: Message) => !m.author.bot, + time: 30000 + }); + + this.messageCollector.on('collect', async (message: Message) => { + if (this.isEnded || !this.currentSong) return; + + const userId = message.author.id; + const username = message.author.username; + const content = message.content; + + let scoreEntry = this.scores.get(userId); + if (!scoreEntry) { + scoreEntry = { userId, username, points: 0 }; + this.scores.set(userId, scoreEntry); + } + + // Check title + if (!this.titleGuessedBy) { + if ( + checkMatch(content, this.currentSong.title, this.currentSong.aliases) + ) { + this.titleGuessedBy = username; + scoreEntry.points += 1; + await message.react('๐ŸŽ‰').catch(() => {}); + await this.textChannel.send( + `โœ… **${username}** guessed the **Song Title**! (+1 pt)` + ); + } + } + + // Check artist + if (!this.artistGuessedBy) { + if ( + checkMatch( + content, + this.currentSong.artist, + this.currentSong.artistAliases + ) + ) { + this.artistGuessedBy = username; + scoreEntry.points += 1; + await message.react('๐Ÿ”ฅ').catch(() => {}); + await this.textChannel.send( + `โœ… **${username}** guessed the **Artist**! (+1 pt)` + ); + } + } + + // If both guessed, end round early + if (this.titleGuessedBy && this.artistGuessedBy) { + if (this.roundTimer) clearTimeout(this.roundTimer); + void this.finishRound(); + } + }); + } + + public async finishRound(): Promise { + if (this.messageCollector) { + this.messageCollector.stop(); + this.messageCollector = null; + } + if (this.roundTimer) { + clearTimeout(this.roundTimer); + this.roundTimer = null; + } + + if (!this.currentSong || this.isEnded) return; + + const revealEmbed = new EmbedBuilder() + .setTitle(`โœจ Round ${this.currentRound} Results`) + .setColor('Purple') + .setDescription( + `**Song:** ${this.currentSong.title}\n` + + `**Artist:** ${this.currentSong.artist}\n\n` + + `โ€ข **Title Guessed By:** ${this.titleGuessedBy || '*Nobody*'}\n` + + `โ€ข **Artist Guessed By:** ${this.artistGuessedBy || '*Nobody*'}\n\n` + + this.getScoreboardText() + ) + .setFooter({ text: 'Next round starting in 4 seconds...' }); + + await this.textChannel.send({ embeds: [revealEmbed] }); + + setTimeout(() => { + if (!this.isEnded) { + void this.nextRound(); + } + }, 4000); + } + + private getScoreboardText(): string { + if (this.scores.size === 0) return '*No points awarded yet.*'; + + const sorted = [...this.scores.values()].sort( + (a, b) => b.points - a.points + ); + return ( + '๐Ÿ“Š **Current Scores:**\n' + + sorted + .map((s, idx) => `${idx + 1}. **${s.username}**: ${s.points} pts`) + .join('\n') + ); + } + + public async endGame(): Promise { + if (this.isEnded) return; + this.isEnded = true; + + if (this.messageCollector) { + this.messageCollector.stop(); + } + if (this.roundTimer) { + clearTimeout(this.roundTimer); + } + + const player = this.player; + if (player) { + await player.disconnect(); + await this.client.music.destroyPlayer(this.guildId); + } + + const sorted = [...this.scores.values()].sort( + (a, b) => b.points - a.points + ); + let finalDescription = '๐Ÿ **The Music Trivia Game has concluded!**\n\n'; + + if (sorted.length === 0) { + finalDescription += + 'No points were scored this game. Thanks for playing!'; + } else { + finalDescription += '๐Ÿ† **Final Leaderboard:**\n'; + const medals = ['๐Ÿฅ‡', '๐Ÿฅˆ', '๐Ÿฅ‰']; + finalDescription += sorted + .map( + (s, idx) => + `${medals[idx] || 'โ–ซ๏ธ'} **${s.username}**: ${s.points} pts` + ) + .join('\n'); + } + + const endEmbed = new EmbedBuilder() + .setTitle('๐ŸŽ‰ Music Trivia - Final Standings') + .setColor('Gold') + .setDescription(finalDescription) + .setTimestamp(); + + await this.textChannel.send({ embeds: [endEmbed] }); + this.client.triviaSessions?.delete(this.guildId); + } + + public async stop(reason = 'Game stopped by user'): Promise { + if (this.isEnded) return; + this.isEnded = true; + + if (this.messageCollector) this.messageCollector.stop(); + if (this.roundTimer) clearTimeout(this.roundTimer); + + const player = this.player; + if (player) { + await player.disconnect(); + await this.client.music.destroyPlayer(this.guildId); + } + + this.client.triviaSessions?.delete(this.guildId); + await this.textChannel.send( + `:octagonal_sign: **Music Trivia stopped:** ${reason}` + ); + } +} diff --git a/apps/bot/src/lib/music/nowPlayingEmbed.ts b/apps/bot/src/lib/music/nowPlayingEmbed.ts index f9f28cf50..1086c499c 100644 --- a/apps/bot/src/lib/music/nowPlayingEmbed.ts +++ b/apps/bot/src/lib/music/nowPlayingEmbed.ts @@ -1,7 +1,5 @@ -import { container } from '@sapphire/framework'; import { ColorResolvable, EmbedBuilder } from 'discord.js'; -import progressbar from 'string-progressbar'; -import type { Song } from './classes/Song'; +import type { Song } from './classes/Song.js'; type PositionType = number | undefined; @@ -33,31 +31,34 @@ export class NowPlayingEmbed { } public async NowPlayingEmbed(): Promise { - let trackLength = this.timeString( - this.millisecondsToTimeObject(this.length) - ); - - const durationText = this.track.isSeekable - ? `:stopwatch: ${trackLength}` - : `:red_circle: Live Stream`; - const userAvatar = this.track.requester?.avatar + const totalMs = + Number(this.length) || + Number(this.track?.length) || + Number((this.track as any)?.info?.duration) || + Number((this.track as any)?.duration) || + 0; + const currentMs = + Number(this.position) || Number((this.track as any)?.position) || 0; + const isSeekable = + this.track?.isSeekable ?? + (this.track as any)?.info?.isSeekable ?? + !(this.track?.isStream || (this.track as any)?.info?.isStream); + + const userAvatar = this.track?.requester?.avatar ? `https://cdn.discordapp.com/avatars/${this.track.requester?.id}/${this.track.requester?.avatar}.png` - : this.track.requester?.defaultAvatarURL ?? - 'https://cdn.discordapp.com/embed/avatars/1.png'; // default Discord Avatar + : (this.track?.requester?.defaultAvatarURL ?? + 'https://cdn.discordapp.com/embed/avatars/1.png'); let embedColor: ColorResolvable; let sourceTxt: string; let sourceIcon: string; - let streamData; - switch (this.track.sourceName) { - case 'soundcloud': { - sourceTxt = 'SoundCloud'; - sourceIcon = - 'https://a-v2.sndcdn.com/assets/images/sc-icons/fluid-b4e7a64b8b.png'; - embedColor = '#F26F23'; - break; - } + const source = + this.track?.sourceName || + (this.track as any)?.info?.sourceName || + 'youtube'; + + switch (source) { case 'vimeo': { sourceTxt = 'Vimeo'; sourceIcon = 'https://i.imgur.com/npxyTWi.png'; @@ -69,20 +70,8 @@ export class NowPlayingEmbed { sourceIcon = 'https://static.twitchcdn.net/assets/favicon-32-e29e246c157142c94346.png'; embedColor = '#6441A5'; - const twitch = container.client.twitch; - if (twitch.auth.access_token) { - try { - streamData = await container.client.twitch.api.getStream({ - login: this.track.author.toLowerCase(), - token: twitch.auth.access_token - }); - } catch { - streamData = undefined; - } - } break; } - case 'youtube': { sourceTxt = 'YouTube'; sourceIcon = @@ -90,121 +79,117 @@ export class NowPlayingEmbed { embedColor = '#FF0000'; break; } - default: { - sourceTxt = 'Somewhere'; + sourceTxt = 'Music Stream'; sourceIcon = 'https://cdn.discordapp.com/embed/avatars/1.png'; - embedColor = 'DarkRed'; + embedColor = '#5865F2'; break; } } const vol = this.volume; - let volumeIcon: string = ':speaker: '; - if (vol > 50) volumeIcon = ':loud_sound: '; - if (vol <= 50 && vol > 20) volumeIcon = ':sound: '; + let volumeIcon: string = ':speaker:'; + if (vol > 50) volumeIcon = ':loud_sound:'; + if (vol <= 50 && vol > 20) volumeIcon = ':sound:'; + const embedFieldData = [ + { + name: 'Artist / Channel', + value: + this.track?.author || + (this.track as any)?.info?.author || + 'Unknown Artist', + inline: true + }, { name: 'Volume', value: `${volumeIcon} ${this.volume}%`, inline: true }, - { name: 'Duration', value: durationText, inline: true } + { + name: 'โฑ๏ธ Progress', + value: this.createProgressBar(currentMs, totalMs, isSeekable), + inline: false + } ]; if (this.queue?.length) { embedFieldData.push( { - name: 'Queue', + name: 'Queue Status', value: `:notes: ${this.queue.length} ${ - this.queue.length == 1 ? 'Song' : 'Songs' - }`, + this.queue.length === 1 ? 'song' : 'songs' + } remaining`, inline: true }, { - name: 'Next', + name: 'Up Next', value: `[${this.queue[0].title}](${this.queue[0].uri})`, inline: false } ); } - const baseEmbed = new EmbedBuilder() + + const embed = new EmbedBuilder() .setTitle( - `${this.paused ? ':pause_button: ' : ':arrow_forward: '} ${ - this.track.title - }` + `${this.paused ? 'โธ๏ธ Paused:' : 'โ–ถ๏ธ Now Playing:'} ${this.track?.title || 'Unknown Track'}` ) .setAuthor({ name: sourceTxt, iconURL: sourceIcon }) - .setURL(this.track.uri) - .setThumbnail(this.track.thumbnail) + .setURL(this.track?.uri || null) + .setThumbnail(this.track?.thumbnail || null) .setColor(embedColor) .addFields(embedFieldData) - .setTimestamp(this.track.added ?? Date.now()) + .setTimestamp(this.track?.added ?? Date.now()) .setFooter({ - text: `Requested By ${this.track.requester?.name}`, + text: `Requested by ${this.track?.requester?.name || 'User'}`, iconURL: userAvatar }); - if (!this.track.isSeekable || this.track.isStream) { - if (streamData && this.track.sourceName == 'twitch') { - const game = `[${ - streamData.game_name - }](https://www.twitch.tv/directory/game/${encodeURIComponent( - streamData.game_name - )})`; - const upTime = this.timeString( - this.millisecondsToTimeObject( - Date.now() - new Date(streamData.started_at).getTime() - ) - ); - return baseEmbed - .setDescription( - `**Game**: ${game}\n**Viewers**: ${ - streamData.viewer_count - }\n**Uptime**: ${upTime}\n **Started**: ` - ) - .setImage( - streamData.thumbnail_url.replace('{width}x{height}', '852x480') + - `?${new Date(streamData.started_at).getTime()}` - ); - } else return baseEmbed; + return embed; + } + + private createProgressBar( + currentMs: number, + totalMs: number, + isSeekable: boolean = true, + barLength: number = 12 + ): string { + if (!isSeekable || !totalMs || totalMs <= 0) { + return '`๐Ÿ”ด LIVE STREAM`'; } - // song just started embed - if (this.position == undefined) this.position = 0; - const bar = progressbar.splitBar(this.length, this.position, 22)[0]; - baseEmbed.setDescription( - `${this.timeString( - this.millisecondsToTimeObject(this.position) - )} ${bar} ${trackLength}` + const clampedCurrent = Math.max(0, Math.min(currentMs, totalMs)); + const percent = clampedCurrent / totalMs; + const filledBlocks = Math.max( + 0, + Math.min(barLength, Math.round(percent * barLength)) ); + const emptyBlocks = Math.max(0, barLength - filledBlocks); - return baseEmbed; - } + const bar = 'โ–ฐ'.repeat(filledBlocks) + 'โ–ฑ'.repeat(emptyBlocks); + const currentStr = this.formatDuration(clampedCurrent); + const totalStr = this.formatDuration(totalMs); - private timeString(timeObject: any) { - if (timeObject[1] === true) return timeObject[0]; - return `${timeObject.hours ? timeObject.hours + ':' : ''}${ - timeObject.minutes ? timeObject.minutes : '00' - }:${ - timeObject.seconds < 10 - ? '0' + timeObject.seconds - : timeObject.seconds - ? timeObject.seconds - : '00' - }`; + return `\`${currentStr}\` ${bar} \`${totalStr}\``; } - private millisecondsToTimeObject(milliseconds: number) { - return { - seconds: Math.floor((milliseconds / 1000) % 60), - minutes: Math.floor((milliseconds / (1000 * 60)) % 60), - hours: Math.floor((milliseconds / (1000 * 60 * 60)) % 24) - }; + private formatDuration(milliseconds: number): string { + if (!milliseconds || isNaN(milliseconds) || milliseconds <= 0) + return '0:00'; + const totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const paddedSeconds = seconds < 10 ? `0${seconds}` : `${seconds}`; + + if (hours > 0) { + const paddedMinutes = minutes < 10 ? `0${minutes}` : `${minutes}`; + return `${hours}:${paddedMinutes}:${paddedSeconds}`; + } + return `${minutes}:${paddedSeconds}`; } } diff --git a/apps/bot/src/lib/music/searchSong.ts b/apps/bot/src/lib/music/searchSong.ts index bf581ca3a..09fb3588b 100644 --- a/apps/bot/src/lib/music/searchSong.ts +++ b/apps/bot/src/lib/music/searchSong.ts @@ -1,109 +1,146 @@ import { container } from '@sapphire/framework'; -import { SpotifyItemType } from '@lavaclient/spotify'; -import { Song } from './classes/Song'; +import { Song } from './classes/Song.js'; import type { User } from 'discord.js'; +import { getApiServiceKeys } from '../../env.js'; + +/** + * Helper check functions for configured API keys / tokens. + */ +function hasSpotifyKeys(): boolean { + const keys = getApiServiceKeys(); + return !!(keys.spotifyClientId && keys.spotifyClientSecret); +} + +function hasYouTubeKeys(): boolean { + const keys = getApiServiceKeys(); + return !!(keys.youtubeApiKey || keys.youtubeRefreshToken); +} + +function hasAnyAudioKeys(): boolean { + // SoundCloud uses Lavalink's built-in source (no API keys required). + // Only YouTube and Spotify require keys to determine if Lavalink should launch. + return hasSpotifyKeys() || hasYouTubeKeys(); +} export default async function searchSong( query: string, user: User ): Promise<[string, Song[]]> { const { client } = container; - let tracks: Song[] = []; - let response; + const tracks: Song[] = []; let displayMessage = ''; const { avatar, defaultAvatarURL, id, displayName } = user; + const requester = { + avatar, + defaultAvatarURL, + id, + name: displayName + }; - if (client.music.spotify.isSpotifyUrl(query)) { - const item = await client.music.spotify.load(query); - switch (item?.type) { - case SpotifyItemType.Track: - const track = await item.resolveYoutubeTrack(); - tracks = [ - new Song(track, Date.now(), { - avatar, - defaultAvatarURL, - id, - name: displayName - }) - ]; - displayMessage = `Queued track [**${item.name}**](${query}).`; - break; - case SpotifyItemType.Artist: - response = await item.resolveYoutubeTracks(); - response.forEach(track => - tracks.push( - new Song(track, Date.now(), { - avatar, - defaultAvatarURL, - id, - name: displayName - }) - ) - ); - displayMessage = `Queued the **Top ${tracks.length} tracks** for [**${item.name}**](${query}).`; - break; - case SpotifyItemType.Album: - case SpotifyItemType.Playlist: - response = await item.resolveYoutubeTracks(); - response.forEach(track => - tracks.push( - new Song(track, Date.now(), { - avatar, - defaultAvatarURL, - id, - name: displayName - }) - ) - ); - displayMessage = `Queued **${ - tracks.length - } tracks** from ${SpotifyItemType[item.type].toLowerCase()} [**${ - item.name - }**](${query}).`; - break; - default: - displayMessage = ":x: Couldn't find what you were looking for :("; - return [displayMessage, tracks]; - } + // 1. Check if any music API keys are configured. If none, Lavalink is disabled. + if (!hasAnyAudioKeys()) { + displayMessage = + ':x: Lavalink audio engine is disabled because no music API keys (YouTube or Spotify) are configured in `.env`.'; return [displayMessage, tracks]; - } else { - const results = await client.music.rest.loadTracks( - /^https?:\/\//.test(query) ? query : `ytsearch:${query}` - ); + } - switch (results.loadType) { - case 'LOAD_FAILED': - case 'NO_MATCHES': - displayMessage = ":x: Couldn't find what you were looking for :("; + try { + const node = client.music.nodeManager.nodes.values().next().value; + if (!node) { + displayMessage = ':x: Lavalink node unavailable.'; + return [displayMessage, tracks]; + } + + // 2. URL gating & direct resolution + if (query.startsWith('http')) { + const lowerQuery = query.toLowerCase(); + if (lowerQuery.includes('spotify.com') && !hasSpotifyKeys()) { + displayMessage = + ':x: Spotify playback is disabled because `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` are not set in `.env`.'; + return [displayMessage, tracks]; + } + if ( + (lowerQuery.includes('youtube.com') || + lowerQuery.includes('youtu.be')) && + !hasYouTubeKeys() + ) { + displayMessage = + ':x: YouTube playback is disabled because no `YOUTUBE_API_KEY` or `YOUTUBE_REFRESH_TOKEN` is configured in `.env`.'; return [displayMessage, tracks]; - case 'PLAYLIST_LOADED': - results.tracks.forEach((track: any) => - tracks.push( - new Song(track, Date.now(), { - avatar, - defaultAvatarURL, - id, - name: displayName - }) - ) - ); - displayMessage = `Queued playlist [**${results.playlistInfo.name}**](${query}), it has a total of **${tracks.length}** tracks.`; - break; - case 'TRACK_LOADED': - case 'SEARCH_RESULT': - const [track] = results.tracks; - tracks = [ - new Song(track, Date.now(), { - avatar, - defaultAvatarURL, - id, - name: displayName - }) - ]; - displayMessage = `Queued [**${track.info.title}**](${track.info.uri})`; - break; + } + + // Direct URL search (SoundCloud URLs handled natively by built-in source) + const searchResult = await node.search({ query }, requester); + return processSearchResult(searchResult, query, requester, tracks); } + // 3. Plain text query: determine search source order based on available keys + // Order of preference: YouTube Music -> YouTube Video -> SoundCloud (free fallback) -> Spotify + const searchSources: string[] = []; + if (hasYouTubeKeys()) { + searchSources.push('ytmsearch'); + searchSources.push('ytsearch'); + } + searchSources.push('scsearch'); // Built-in source, no API keys needed + if (hasSpotifyKeys()) searchSources.push('spsearch'); + + for (const source of searchSources) { + const searchResult = await node.search( + { query, source: source as any }, + requester + ); + if ( + searchResult && + searchResult.tracks && + searchResult.tracks.length > 0 && + searchResult.loadType !== 'empty' && + searchResult.loadType !== 'error' + ) { + return processSearchResult(searchResult, query, requester, tracks); + } + } + + displayMessage = ":x: Couldn't find what you were looking for :("; + } catch (err) { + displayMessage = ":x: Couldn't find what you were looking for :("; + } + + return [displayMessage, tracks]; +} + +function processSearchResult( + searchResult: any, + query: string, + requester: any, + tracks: Song[] +): [string, Song[]] { + let displayMessage = ''; + if ( + !searchResult || + !searchResult.tracks || + searchResult.tracks.length === 0 || + searchResult.loadType === 'empty' || + searchResult.loadType === 'error' + ) { + displayMessage = ":x: Couldn't find what you were looking for :("; return [displayMessage, tracks]; } + + if (searchResult.loadType === 'playlist') { + searchResult.tracks.forEach((track: any) => + tracks.push(new Song(track, Date.now(), requester)) + ); + displayMessage = `Queued playlist [**${ + searchResult.playlist?.name || 'Playlist' + }**](<${query}>), it has a total of **${tracks.length}** tracks.`; + } else if ( + searchResult.loadType === 'search' || + searchResult.loadType === 'track' + ) { + const track = searchResult.tracks[0]; + tracks.push(new Song(track, Date.now(), requester)); + displayMessage = `Queued [**${track.info.title}**](<${track.info.uri}>)`; + } + + return [displayMessage, tracks]; } diff --git a/apps/bot/src/lib/music/triviaMatcher.ts b/apps/bot/src/lib/music/triviaMatcher.ts new file mode 100644 index 000000000..fa4da600a --- /dev/null +++ b/apps/bot/src/lib/music/triviaMatcher.ts @@ -0,0 +1,67 @@ +export function normalizeText(text: string): string { + return text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\(.*?\)|\[.*?\]/g, '') + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function levenshtein(a: string, b: string): number { + const matrix: number[][] = []; + + for (let i = 0; i <= b.length; i++) { + matrix[i] = [i]; + } + for (let j = 0; j <= a.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= b.length; i++) { + for (let j = 1; j <= a.length; j++) { + if (b.charAt(i - 1) === a.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ); + } + } + } + + return matrix[b.length][a.length]; +} + +export function checkMatch( + guess: string, + target: string, + aliases: string[] = [] +): boolean { + const cleanGuess = normalizeText(guess); + if (!cleanGuess || cleanGuess.length < 2) return false; + + const allTargets = [target, ...aliases].map(normalizeText).filter(Boolean); + + for (const t of allTargets) { + if (cleanGuess === t) return true; + if (cleanGuess.includes(t) || t.includes(cleanGuess)) { + if ( + cleanGuess.length >= t.length * 0.6 || + t.length >= cleanGuess.length * 0.6 + ) { + return true; + } + } + + const maxDistance = t.length > 8 ? 2 : t.length > 4 ? 1 : 0; + if (levenshtein(cleanGuess, t) <= maxDistance) { + return true; + } + } + + return false; +} diff --git a/apps/bot/src/lib/music/triviaSongs.ts b/apps/bot/src/lib/music/triviaSongs.ts new file mode 100644 index 000000000..7bf844dbf --- /dev/null +++ b/apps/bot/src/lib/music/triviaSongs.ts @@ -0,0 +1,240 @@ +export interface TriviaSong { + title: string; + artist: string; + aliases?: string[]; + artistAliases?: string[]; + query: string; + category: 'pop' | 'rock' | '80s' | '90s' | '2000s' | '2010s' | 'modern'; +} + +export const TRIVIA_SONGS: TriviaSong[] = [ + // 80s + { + title: 'Billie Jean', + artist: 'Michael Jackson', + aliases: ['billie jean'], + artistAliases: ['mj'], + query: 'ytmsearch:Michael Jackson Billie Jean', + category: '80s' + }, + { + title: 'Take On Me', + artist: 'a-ha', + aliases: ['take on me'], + artistAliases: ['aha'], + query: 'ytmsearch:a-ha Take On Me', + category: '80s' + }, + { + title: 'Sweet Child O Mine', + artist: "Guns N' Roses", + aliases: ["sweet child o' mine", 'sweet child of mine'], + artistAliases: ['guns n roses', 'gnr'], + query: "ytmsearch:Guns N' Roses Sweet Child O' Mine", + category: '80s' + }, + { + title: 'Never Gonna Give You Up', + artist: 'Rick Astley', + aliases: ['never gonna give you up', 'rickroll'], + artistAliases: ['rick astley'], + query: 'ytmsearch:Rick Astley Never Gonna Give You Up', + category: '80s' + }, + { + title: "Livin' On A Prayer", + artist: 'Bon Jovi', + aliases: ['livin on a prayer', 'living on a prayer'], + artistAliases: ['bon jovi'], + query: "ytmsearch:Bon Jovi Livin' On A Prayer", + category: '80s' + }, + { + title: 'Africa', + artist: 'Toto', + aliases: ['africa'], + artistAliases: ['toto'], + query: 'ytmsearch:Toto Africa', + category: '80s' + }, + // 90s + { + title: 'Smells Like Teen Spirit', + artist: 'Nirvana', + aliases: ['smells like teen spirit'], + artistAliases: ['nirvana'], + query: 'ytmsearch:Nirvana Smells Like Teen Spirit', + category: '90s' + }, + { + title: 'Wonderwall', + artist: 'Oasis', + aliases: ['wonderwall'], + artistAliases: ['oasis'], + query: 'ytmsearch:Oasis Wonderwall', + category: '90s' + }, + { + title: 'Wannabe', + artist: 'Spice Girls', + aliases: ['wannabe'], + artistAliases: ['spice girls'], + query: 'ytmsearch:Spice Girls Wannabe', + category: '90s' + }, + { + title: 'No Scrubs', + artist: 'TLC', + aliases: ['no scrubs'], + artistAliases: ['tlc'], + query: 'ytmsearch:TLC No Scrubs', + category: '90s' + }, + { + title: 'Gangstas Paradise', + artist: 'Coolio', + aliases: ["gangsta's paradise", 'gangstas paradise', 'gangsta paradise'], + artistAliases: ['coolio'], + query: "ytmsearch:Coolio Gangsta's Paradise", + category: '90s' + }, + // 2000s + { + title: 'In The End', + artist: 'Linkin Park', + aliases: ['in the end'], + artistAliases: ['linkin park', 'lp'], + query: 'ytmsearch:Linkin Park In The End', + category: '2000s' + }, + { + title: 'Toxic', + artist: 'Britney Spears', + aliases: ['toxic'], + artistAliases: ['britney spears', 'britney'], + query: 'ytmsearch:Britney Spears Toxic', + category: '2000s' + }, + { + title: 'Seven Nation Army', + artist: 'The White Stripes', + aliases: ['seven nation army'], + artistAliases: ['the white stripes', 'white stripes'], + query: 'ytmsearch:The White Stripes Seven Nation Army', + category: '2000s' + }, + { + title: 'Hey Ya', + artist: 'Outkast', + aliases: ['hey ya!', 'hey ya'], + artistAliases: ['outkast'], + query: 'ytmsearch:Outkast Hey Ya!', + category: '2000s' + }, + { + title: 'Mr Brightside', + artist: 'The Killers', + aliases: ['mr brightside', 'mr. brightside'], + artistAliases: ['the killers', 'killers'], + query: 'ytmsearch:The Killers Mr Brightside', + category: '2000s' + }, + { + title: 'Viva La Vida', + artist: 'Coldplay', + aliases: ['viva la vida'], + artistAliases: ['coldplay'], + query: 'ytmsearch:Coldplay Viva La Vida', + category: '2000s' + }, + // 2010s + { + title: 'Rolling in the Deep', + artist: 'Adele', + aliases: ['rolling in the deep'], + artistAliases: ['adele'], + query: 'ytmsearch:Adele Rolling in the Deep', + category: '2010s' + }, + { + title: 'Shape of You', + artist: 'Ed Sheeran', + aliases: ['shape of you'], + artistAliases: ['ed sheeran'], + query: 'ytmsearch:Ed Sheeran Shape of You', + category: '2010s' + }, + { + title: 'Uptown Funk', + artist: 'Bruno Mars', + aliases: ['uptown funk'], + artistAliases: ['bruno mars', 'mark ronson'], + query: 'ytmsearch:Mark Ronson Uptown Funk Bruno Mars', + category: '2010s' + }, + { + title: 'Counting Stars', + artist: 'OneRepublic', + aliases: ['counting stars'], + artistAliases: ['onerepublic', 'one republic'], + query: 'ytmsearch:OneRepublic Counting Stars', + category: '2010s' + }, + { + title: 'Bad Guy', + artist: 'Billie Eilish', + aliases: ['bad guy'], + artistAliases: ['billie eilish'], + query: 'ytmsearch:Billie Eilish bad guy', + category: '2010s' + }, + { + title: 'Old Town Road', + artist: 'Lil Nas X', + aliases: ['old town road'], + artistAliases: ['lil nas x'], + query: 'ytmsearch:Lil Nas X Old Town Road', + category: '2010s' + }, + // Modern + { + title: 'Blinding Lights', + artist: 'The Weeknd', + aliases: ['blinding lights'], + artistAliases: ['the weeknd', 'weeknd'], + query: 'ytmsearch:The Weeknd Blinding Lights', + category: 'modern' + }, + { + title: 'Levitating', + artist: 'Dua Lipa', + aliases: ['levitating'], + artistAliases: ['dua lipa'], + query: 'ytmsearch:Dua Lipa Levitating', + category: 'modern' + }, + { + title: 'Stay', + artist: 'The Kid LAROI & Justin Bieber', + aliases: ['stay'], + artistAliases: ['the kid laroi', 'justin bieber', 'kid laroi'], + query: 'ytmsearch:The Kid LAROI Justin Bieber Stay', + category: 'modern' + }, + { + title: 'As It Was', + artist: 'Harry Styles', + aliases: ['as it was'], + artistAliases: ['harry styles'], + query: 'ytmsearch:Harry Styles As It Was', + category: 'modern' + }, + { + title: 'Flowers', + artist: 'Miley Cyrus', + aliases: ['flowers'], + artistAliases: ['miley cyrus'], + query: 'ytmsearch:Miley Cyrus Flowers', + category: 'modern' + } +]; diff --git a/apps/bot/src/lib/music/youtubeOAuth.ts b/apps/bot/src/lib/music/youtubeOAuth.ts new file mode 100644 index 000000000..aa85fbb0e --- /dev/null +++ b/apps/bot/src/lib/music/youtubeOAuth.ts @@ -0,0 +1,195 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import type { Client, User } from 'discord.js'; +import Logger from '../logger.js'; + +const __dirname = fileURLToPath(new URL('.', import.meta.url)); + +const CLIENT_ID = + '861556708454-d6dlm3lh05idd8npek18k6be8ba3oc68.apps.googleusercontent.com'; +const CLIENT_SECRET = 'SboVhoG9s0rNafixCSGGKXAT'; +const SCOPE = + 'http://gdata.youtube.com https://www.googleapis.com/auth/youtube'; +const DEVICE_CODE_URL = 'https://www.youtube.com/o/oauth2/device/code'; +const TOKEN_URL = 'https://www.youtube.com/o/oauth2/token'; + +export interface DeviceFlowResponse { + device_code: string; + user_code: string; + verification_url: string; + expires_in: number; + interval: number; +} + +/** + * Initiates the Google OAuth 2.0 Device Authorization Flow for YouTube (InnerTube TV endpoint). + */ +export async function initiateDeviceFlow(): Promise { + const deviceId = crypto.randomUUID().replace(/-/g, ''); + const payload = { + client_id: CLIENT_ID, + scope: SCOPE, + device_id: deviceId, + device_model: 'ytlr::' + }; + + const res = await fetch(DEVICE_CODE_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }, + body: JSON.stringify(payload) + }); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Device code request failed (${res.status}): ${errorText}`); + } + + const data = (await res.json()) as any; + return { + device_code: data.device_code, + user_code: data.user_code, + verification_url: data.verification_url || 'https://www.google.com/device', + expires_in: data.expires_in || 1800, + interval: data.interval || 5 + }; +} + +/** + * Polls YouTube OAuth token endpoint until the user authorizes the device code. + */ +export async function pollForRefreshToken( + deviceCode: string, + interval = 5, + expiresIn = 1800 +): Promise { + const startTime = Date.now(); + const pollIntervalMs = Math.max(interval, 5) * 1000; + + return new Promise(resolve => { + const timer = setInterval(async () => { + if (Date.now() - startTime > expiresIn * 1000) { + clearInterval(timer); + resolve(null); + return; + } + + try { + const payload = { + client_id: CLIENT_ID, + client_secret: CLIENT_SECRET, + code: deviceCode, + grant_type: 'http://oauth.net/grant_type/device/1.0' + }; + + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + }, + body: JSON.stringify(payload) + }); + + const data = (await res.json()) as any; + + if (res.ok && data?.refresh_token) { + clearInterval(timer); + const refreshToken = data.refresh_token as string; + saveYouTubeRefreshToken(refreshToken); + resolve(refreshToken); + return; + } + + if ( + data?.error === 'authorization_pending' || + data?.error === 'slow_down' + ) { + return; + } + + clearInterval(timer); + Logger.error( + `OAuth Polling Error: ${data?.error_description || data?.error}` + ); + resolve(null); + } catch (err: any) { + clearInterval(timer); + Logger.error(`OAuth Request Error: ${err?.message || err}`); + resolve(null); + } + }, pollIntervalMs); + }); +} + +/** + * Atomically saves the YouTube OAuth refresh token to .youtube-oauth.json (gitignored) + * and updates process.env in memory. (Strict compliance with Rule 2: Zero .env mutation). + */ +export function saveYouTubeRefreshToken(token: string): void { + if (!token || !token.startsWith('1/')) return; + + process.env.YOUTUBE_REFRESH_TOKEN = token; + + const candidateDirs = [ + path.resolve(process.cwd(), '../../'), + process.cwd(), + path.resolve(__dirname, '../../../../') + ]; + + for (const dir of candidateDirs) { + const filePath = path.join(dir, '.youtube-oauth.json'); + const tmpPath = `${filePath}.tmp`; + try { + const data = JSON.stringify( + { + refresh_token: token, + updated_at: new Date().toISOString() + }, + null, + 2 + ); + fs.writeFileSync(tmpPath, data, 'utf-8'); + fs.renameSync(tmpPath, filePath); + Logger.info( + `YouTube OAuth refresh token saved atomically to ${filePath}` + ); + break; + } catch (err) { + Logger.error(`Failed to save .youtube-oauth.json in ${dir}: ${err}`); + } + } +} + +/** + * Fetches the Discord Application Owner to restrict sensitive administrative commands. + */ +export async function getApplicationOwnerUser( + client: Client +): Promise { + try { + await client.application?.fetch(); + const app = client.application; + if (!app || !app.owner) return null; + + let ownerId: string | null = null; + if ('ownerId' in app.owner && app.owner.ownerId) { + ownerId = app.owner.ownerId as string; + } else if ('id' in app.owner && app.owner.id) { + ownerId = app.owner.id; + } + + if (ownerId) { + return await client.users.fetch(ownerId).catch(() => null); + } + } catch (err) { + Logger.error(`Failed to fetch application owner user: ${err}`); + } + return null; +} diff --git a/apps/bot/src/lib/presence/StatusManager.ts b/apps/bot/src/lib/presence/StatusManager.ts new file mode 100644 index 000000000..be1bbf48c --- /dev/null +++ b/apps/bot/src/lib/presence/StatusManager.ts @@ -0,0 +1,139 @@ +import { ActivityType, type Client } from 'discord.js'; +import Logger from '../logger.js'; + +interface StatusItem { + text: string | ((client: Client) => string); + type: ActivityType; +} + +export class StatusManager { + private static client: Client | null = null; + private static interval: NodeJS.Timeout | null = null; + private static currentIndex = 0; + + private static readonly statuses: StatusItem[] = [ + { + text: '/help โ€ข /play', + type: ActivityType.Listening + }, + { + text: client => { + const serverCount = client.guilds.cache.size; + return `/help | ${serverCount} server${serverCount === 1 ? '' : 's'}`; + }, + type: ActivityType.Watching + }, + { + text: '/play โ€ข High-Fidelity Audio ๐ŸŽต', + type: ActivityType.Listening + }, + { + text: client => { + const userCount = client.guilds.cache.reduce( + (total, guild) => total + (guild.memberCount || 0), + 0 + ); + return `/reminder โ€ข ${userCount.toLocaleString()} members`; + }, + type: ActivityType.Watching + }, + { + text: '/connect-four โ€ข /tic-tac-toe ๐ŸŽฎ', + type: ActivityType.Competing + }, + { + text: '/dashboard โ€ข Web Management ๐ŸŒ', + type: ActivityType.Playing + } + ]; + + public static start(client: Client, rotationIntervalSeconds = 25): void { + this.client = client; + if (this.interval) clearInterval(this.interval); + + // Set initial activity immediately + this.updatePresence(); + + // Schedule periodic rotation + this.interval = setInterval(() => { + this.updatePresence(); + }, rotationIntervalSeconds * 1000); + + Logger.info( + `StatusManager initialized with ${this.statuses.length} rotating presence statuses (${rotationIntervalSeconds}s interval).` + ); + } + + public static stop(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + this.client = null; + } + + public static updatePresence(): void { + if (!this.client?.user) return; + + try { + // Check if any players are actively playing music + const extendedClient = this.client as any; + const players = extendedClient.music?.players; + let activePlayingCount = 0; + let currentTrackTitle: string | null = null; + + if (players && typeof players.values === 'function') { + for (const player of players.values()) { + if (player.playing && player.queue?.current) { + activePlayingCount++; + if (!currentTrackTitle) { + currentTrackTitle = player.queue.current.info.title; + } + } + } + } + + // If music is actively playing in servers, occasionally feature music status + if ( + activePlayingCount > 0 && + this.currentIndex % 2 === 0 && + currentTrackTitle + ) { + const displayTitle = + currentTrackTitle.length > 40 + ? `${currentTrackTitle.slice(0, 37)}...` + : currentTrackTitle; + + this.client.user.setPresence({ + status: 'online', + activities: [ + { + name: `๐ŸŽต ${displayTitle}`, + type: ActivityType.Listening + } + ] + }); + this.currentIndex = (this.currentIndex + 1) % this.statuses.length; + return; + } + + const item = this.statuses[this.currentIndex]; + const text = + typeof item.text === 'function' ? item.text(this.client) : item.text; + + this.client.user.setPresence({ + status: 'online', + activities: [ + { + name: text, + type: item.type + } + ] + }); + + this.currentIndex = (this.currentIndex + 1) % this.statuses.length; + } catch (err) { + Logger.error('StatusManager failed to update presence:', err); + } + } +} diff --git a/apps/bot/src/lib/reminders/ReminderManager.ts b/apps/bot/src/lib/reminders/ReminderManager.ts new file mode 100644 index 000000000..e5415463c --- /dev/null +++ b/apps/bot/src/lib/reminders/ReminderManager.ts @@ -0,0 +1,202 @@ +import { EmbedBuilder, type Client, type User } from 'discord.js'; +import { dataService } from '../../dataService.js'; +import Logger from '../logger.js'; + +export interface FormatContext { + userId: string; + user?: User | null; + event: string; + dateTime: string; +} + +export function formatReminderText( + template: string, + ctx: FormatContext +): string { + if (!template) return ''; + + const date = new Date(ctx.dateTime); + const unix = !isNaN(date.getTime()) + ? Math.floor(date.getTime() / 1000) + : Math.floor(Date.now() / 1000); + + const dateStr = !isNaN(date.getTime()) + ? date.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }) + : 'Unknown Date'; + + const timeStr = !isNaN(date.getTime()) + ? date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }) + : 'Unknown Time'; + + const username = ctx.user?.username || 'Member'; + const mention = `<@${ctx.userId}>`; + + return template + .replace(/\{user\}|\{mention\}/gi, mention) + .replace(/\{username\}/gi, username) + .replace(/\{event\}/gi, ctx.event) + .replace(/\{date\}/gi, dateStr) + .replace(/\{time\}/gi, timeStr) + .replace(/\{countdown\}|\{relative\}|\{timestamp\}/gi, ``); +} + +export class ReminderManager { + private static client: Client | null = null; + private static interval: NodeJS.Timeout | null = null; + private static isProcessing = false; + + public static start(client: Client): void { + this.client = client; + if (this.interval) clearInterval(this.interval); + + // Run check immediately and then every 30 seconds + this.checkDueReminders().catch(err => + Logger.error('Initial reminder check error: ', err) + ); + this.interval = setInterval(() => { + this.checkDueReminders().catch(err => + Logger.error('Interval reminder check error: ', err) + ); + }, 30 * 1000); + + Logger.info( + 'ReminderManager background scheduler initialized (30s interval).' + ); + } + + public static stop(): void { + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } + + public static async checkDueReminders(): Promise { + if (!this.client || this.isProcessing) return; + this.isProcessing = true; + + try { + const nowIso = new Date().toISOString(); + const result = await dataService.reminder.getDueReminders({ + beforeIsoDate: nowIso + }); + const dueReminders = result.reminders || []; + + if (dueReminders.length === 0) { + this.isProcessing = false; + return; + } + + for (const reminder of dueReminders) { + try { + const user = await this.client.users + .fetch(reminder.userId) + .catch(() => null); + const date = new Date(reminder.dateTime); + const unix = !isNaN(date.getTime()) + ? Math.floor(date.getTime() / 1000) + : Math.floor(Date.now() / 1000); + + const formattedDescription = reminder.description + ? formatReminderText(reminder.description, { + userId: reminder.userId, + user, + event: reminder.event, + dateTime: reminder.dateTime + }) + : null; + + const formattedEvent = formatReminderText(reminder.event, { + userId: reminder.userId, + user, + event: reminder.event, + dateTime: reminder.dateTime + }); + + const embed = new EmbedBuilder() + .setTitle('๐Ÿ”” Scheduled Reminder') + .setColor(0xfee75c) + .setDescription( + `Hey ${user ? user : `<@${reminder.userId}>`}, here is your reminder for **${formattedEvent}**!` + ) + .addFields( + { name: '๐Ÿ“ Event', value: formattedEvent, inline: true }, + { + name: 'โฐ Scheduled For', + value: ` ()`, + inline: true + } + ) + .setFooter({ + text: 'Master-Bot Reminder System', + iconURL: this.client.user?.displayAvatarURL() + }) + .setTimestamp(); + + if (formattedDescription) { + embed.addFields({ + name: '๐Ÿ“„ Notes', + value: formattedDescription, + inline: false + }); + } + + let delivered = false; + if (user) { + delivered = await user + .send({ embeds: [embed] }) + .then(() => true) + .catch(() => false); + } + + // If DM failed (DMs closed), attempt to notify in a mutual guild text channel if available + if (!delivered && user) { + for (const guild of this.client.guilds.cache.values()) { + const member = guild.members.cache.get(user.id); + if (member) { + const systemChannel = + guild.systemChannel || + guild.channels.cache.find( + c => c.isTextBased() && 'send' in c + ); + if (systemChannel && 'send' in systemChannel) { + await (systemChannel as any) + .send({ + content: `๐Ÿ”” <@${user.id}> (Your DMs are closed)`, + embeds: [embed] + }) + .catch(() => {}); + break; + } + } + } + } + + // Delete dispatched reminder + await dataService.reminder.delete({ + userId: reminder.userId, + event: reminder.event + }) + .catch(() => {}); + } catch (reminderErr) { + Logger.error( + `Error processing reminder #${reminder.id}: `, + reminderErr + ); + } + } + } catch (err) { + Logger.error('ReminderManager execution failed: ', err); + } finally { + this.isProcessing = false; + } + } +} diff --git a/apps/bot/src/lib/setup.ts b/apps/bot/src/lib/setup.ts index 8598206ba..cde4f9458 100644 --- a/apps/bot/src/lib/setup.ts +++ b/apps/bot/src/lib/setup.ts @@ -2,7 +2,6 @@ import { ApplicationCommandRegistries, RegisterBehavior } from '@sapphire/framework'; -import '@sapphire/plugin-api/register'; import '@sapphire/plugin-editable-commands/register'; import '@sapphire/plugin-subcommands/register'; import * as colorette from 'colorette'; diff --git a/apps/bot/src/lib/structures/CommandHelp.ts b/apps/bot/src/lib/structures/CommandHelp.ts new file mode 100644 index 000000000..12e5b42b1 --- /dev/null +++ b/apps/bot/src/lib/structures/CommandHelp.ts @@ -0,0 +1,28 @@ +import { isCommandNameGloballyDisabled } from '../../preconditions/isCommandDisabled.js'; + +export interface CommandHelpOption { + name: string; + description: string; + required?: boolean; +} + +export interface CommandHelp { + name: string; + category: string; + description: string; + usage?: string; + examples?: string[]; + options?: CommandHelpOption[]; + disabled?: boolean; +} + +export function isCommandHelpEnabled(help: CommandHelp): boolean { + if (help.disabled) return false; + if ( + isCommandNameGloballyDisabled(help.name) || + isCommandNameGloballyDisabled(help.category) + ) { + return false; + } + return true; +} diff --git a/apps/bot/src/lib/structures/ExtendedClient.ts b/apps/bot/src/lib/structures/ExtendedClient.ts index 27c768d90..6366e1618 100644 --- a/apps/bot/src/lib/structures/ExtendedClient.ts +++ b/apps/bot/src/lib/structures/ExtendedClient.ts @@ -1,22 +1,26 @@ import { SapphireClient } from '@sapphire/framework'; import '@sapphire/plugin-hmr/register'; -import { QueueClient } from '../music/classes/QueueClient'; -import Redis from 'ioredis'; +import { QueueClient } from '../music/classes/QueueClient.js'; import { - GatewayDispatchEvents, IntentsBitField, NewsChannel, TextChannel, ThreadChannel } from 'discord.js'; -import { deletePlayerEmbed } from '../music/buttonsCollector'; -import type { ClientTwitchExtension } from './../../lib/twitch/twitchAPI-types'; -import { TwitchAPI } from '../twitch/twitchAPI'; -import Logger from '../logger'; +import { deletePlayerEmbed } from '../music/buttonsCollector.js'; +import type { ClientTwitchExtension } from './../../lib/twitch/twitchAPI-types.js'; +import { TwitchAPI } from '../twitch/twitchAPI.js'; +import Logger from '../logger.js'; +import type { TriviaSession } from '../music/classes/TriviaSession.js'; + +export interface ExtendedClientOptions { + withPrivilegedIntents?: boolean; +} export class ExtendedClient extends SapphireClient { readonly music: QueueClient; leaveTimers: { [key: string]: NodeJS.Timeout }; + triviaSessions: Map = new Map(); twitch: ClientTwitchExtension = { api: new TwitchAPI( process.env.TWITCH_CLIENT_ID, @@ -31,54 +35,56 @@ export class ExtendedClient extends SapphireClient { }, notifyList: {} }; - public constructor() { + + public constructor(options?: ExtendedClientOptions) { + const withPrivileged = options?.withPrivilegedIntents ?? true; + const intents = [ + IntentsBitField.Flags.Guilds, + IntentsBitField.Flags.GuildMessages, + IntentsBitField.Flags.GuildMessageReactions, + IntentsBitField.Flags.GuildVoiceStates + ]; + + if (withPrivileged) { + intents.push(IntentsBitField.Flags.GuildMembers); + } + super({ - intents: [ - IntentsBitField.Flags.Guilds, - IntentsBitField.Flags.GuildMembers, - IntentsBitField.Flags.GuildMessages, - IntentsBitField.Flags.GuildMessageReactions, - IntentsBitField.Flags.GuildVoiceStates - ], + intents, logger: { level: 100 }, - loadMessageCommandListeners: true, + loadMessageCommandListeners: false, hmr: { enabled: process.env.NODE_ENV === 'development' } }); this.music = new QueueClient({ - sendGatewayPayload: (id, payload) => - this.guilds.cache.get(id)?.shard?.send(payload), - options: { - redis: new Redis({ - host: process.env.REDIS_HOST || 'localhost', - port: Number.parseInt(process.env.REDIS_PORT!) || 6379, - password: process.env.REDIS_PASSWORD || '', - db: Number.parseInt(process.env.REDIS_DB!) || 0 - }) + node: { + host: + process.env.LAVA_HOST && process.env.LAVA_HOST !== '0.0.0.0' + ? process.env.LAVA_HOST + : '127.0.0.1', + authorization: process.env.LAVA_PASS || 'youshallnotpass', + port: process.env.LAVA_PORT ? +process.env.LAVA_PORT : 2333, + secure: process.env.LAVA_SECURE === 'true', + id: 'main' }, - connection: { - host: process.env.LAVA_HOST || '', - password: process.env.LAVA_PASS || '', - port: process.env.LAVA_PORT ? +process.env.LAVA_PORT : 1339, - secure: process.env.LAVA_SECURE === 'true' ? true : false - } + clientId: process.env.DISCORD_CLIENT_ID }); - this.ws.on(GatewayDispatchEvents.VoiceServerUpdate, async data => { - await this.music.handleVoiceUpdate(data); - }); - - this.ws.on(GatewayDispatchEvents.VoiceStateUpdate, async data => { - // handle if a mod right-clicks disconnect on the bot - if (!data.channel_id && data.user_id == this.application?.id) { - const queue = this.music.queues.get(data.guild_id); - await deletePlayerEmbed(queue); - await queue.clear(); - queue.destroyPlayer(); + this.on('raw', async (data: any) => { + if (data.t === 'VOICE_STATE_UPDATE') { + const d = data.d; + if (!d.channel_id && d.user_id === this.application?.id) { + const queue = this.music.queues.get(d.guild_id); + if (queue) { + await deletePlayerEmbed(queue); + await queue.clear(); + await queue.destroyPlayer(); + } + } } - await this.music.handleVoiceUpdate(data); + await this.music.sendRawData(data); }); if (process.env.TWITCH_CLIENT_ID && process.env.TWITCH_CLIENT_SECRET) { @@ -121,15 +127,17 @@ declare module '@sapphire/framework' { interface SapphireClient { readonly music: QueueClient; leaveTimers: { [key: string]: NodeJS.Timeout }; + triviaSessions: Map; twitch: ClientTwitchExtension; } } -declare module 'lavaclient' { +declare module 'lavalink-client' { interface Player { - nightcore: boolean; - vaporwave: boolean; - karaoke: boolean; - bassboost: boolean; + nightcore?: boolean; + vaporwave?: boolean; + karaoke?: boolean; + bassboost?: boolean; } } + diff --git a/apps/bot/src/lib/structures/HelpRegistry.ts b/apps/bot/src/lib/structures/HelpRegistry.ts new file mode 100644 index 000000000..e8319cb05 --- /dev/null +++ b/apps/bot/src/lib/structures/HelpRegistry.ts @@ -0,0 +1,116 @@ +import { createRequire } from 'node:module'; +import { container } from '@sapphire/framework'; +import { isCommandNameGloballyDisabled } from '../../preconditions/isCommandDisabled.js'; +import type { CommandHelp } from './CommandHelp.js'; + +const require = createRequire(import.meta.url); + +export class HelpRegistry { + private static getHelpFromCommand(cmd: any): CommandHelp | undefined { + if (cmd.help) return cmd.help; + try { + if (cmd.location?.full) { + const mod = require(cmd.location.full); + if (mod?.help) return mod.help; + } + } catch {} + return undefined; + } + + /** + * Retrieves all enabled commands formatted as CommandHelp items. + * Dynamically pulls from Sapphire's active command store and validates against + * isCommandDisabled state (including LAVA_ENABLED). + */ + public static getEnabledCommands(): CommandHelp[] { + const commandsStore = container.stores.get('commands'); + const result: CommandHelp[] = []; + + commandsStore.forEach(cmd => { + const helpMeta = this.getHelpFromCommand(cmd); + const category = + helpMeta?.category?.toLowerCase() || + cmd.category?.toLowerCase() || + 'other'; + + // Filter out disabled commands or categories using central isCommandDisabled check + if (!cmd.enabled) return; + if ( + isCommandNameGloballyDisabled(cmd.name) || + isCommandNameGloballyDisabled(category) + ) { + return; + } + + result.push({ + name: cmd.name, + category, + description: + helpMeta?.description || cmd.description || `${cmd.name} command`, + usage: helpMeta?.usage || `/${cmd.name}`, + examples: helpMeta?.examples || [`/${cmd.name}`], + options: helpMeta?.options || [], + disabled: false + }); + }); + + return result.sort((a, b) => a.name.localeCompare(b.name)); + } + + /** + * Retrieves enabled commands grouped by category. + */ + public static getCategoriesMap(): Map { + const commands = this.getEnabledCommands(); + const map = new Map(); + + for (const cmd of commands) { + if (!map.has(cmd.category)) { + map.set(cmd.category, []); + } + map.get(cmd.category)!.push(cmd); + } + + return map; + } + + /** + * Finds a specific command help item by name, checking enablement against isCommandDisabled. + */ + public static getCommand(name: string): { + help: CommandHelp | null; + disabled: boolean; + } { + const cleanName = name.toLowerCase().replace(/^\//, ''); + const commandsStore = container.stores.get('commands'); + const cmd = commandsStore.get(cleanName); + + if (!cmd) { + return { help: null, disabled: false }; + } + + const helpMeta = this.getHelpFromCommand(cmd); + const category = + helpMeta?.category?.toLowerCase() || + cmd.category?.toLowerCase() || + 'other'; + const isDisabled = + !cmd.enabled || + isCommandNameGloballyDisabled(cmd.name) || + isCommandNameGloballyDisabled(category); + + return { + help: { + name: cmd.name, + category, + description: + helpMeta?.description || cmd.description || `${cmd.name} command`, + usage: helpMeta?.usage || `/${cmd.name}`, + examples: helpMeta?.examples || [`/${cmd.name}`], + options: helpMeta?.options || [], + disabled: isDisabled + }, + disabled: isDisabled + }; + } +} diff --git a/apps/bot/src/lib/twitch/TwitchEmbed.ts b/apps/bot/src/lib/twitch/TwitchEmbed.ts index 7302a475a..9f646d76a 100644 --- a/apps/bot/src/lib/twitch/TwitchEmbed.ts +++ b/apps/bot/src/lib/twitch/TwitchEmbed.ts @@ -1,4 +1,4 @@ -import type { TwitchStream } from './twitchAPI-types'; +import type { TwitchStream } from './twitchAPI-types.js'; import { EmbedBuilder } from 'discord.js'; export class TwitchEmbed { stream: TwitchStream; diff --git a/apps/bot/src/lib/twitch/notifyChannels.ts b/apps/bot/src/lib/twitch/notifyChannels.ts index 3ad077b36..2c8cf3ecd 100644 --- a/apps/bot/src/lib/twitch/notifyChannels.ts +++ b/apps/bot/src/lib/twitch/notifyChannels.ts @@ -1,10 +1,10 @@ -import { MessageChannel } from './../structures/ExtendedClient'; -import type { TwitchGame, TwitchStream } from './twitchAPI-types'; -import { TwitchEmbed } from './TwitchEmbed'; +import { MessageChannel } from './../structures/ExtendedClient.js'; +import type { TwitchGame, TwitchStream } from './twitchAPI-types.js'; +import { TwitchEmbed } from './TwitchEmbed.js'; import { container } from '@sapphire/framework'; import type { Message } from 'discord.js'; -import { trpcNode } from '../../trpc'; -import Logger from '../logger'; +import { dataService } from '../../dataService.js'; +import Logger from '../logger.js'; // Twitch ids are non changeable, usernames are not good for reference export async function notify(query: string[]) { @@ -108,7 +108,7 @@ export async function notify(query: string[]) { client.twitch.notifyList[entry].messageSent = true; // Update DataBase - await trpcNode.twitch.updateNotificationStatus.mutate({ + await dataService.twitch.updateNotificationStatus({ userId: entry, sent: true, live: true @@ -204,7 +204,7 @@ export async function notify(query: string[]) { client.twitch.notifyList[entry].messageSent = false; client.twitch.notifyList[entry].messageHandler = {}; // Update DataBase - await trpcNode.twitch.updateNotificationStatus.mutate({ + await dataService.twitch.updateNotificationStatus({ userId: entry, sent: false, live: false diff --git a/apps/bot/src/lib/twitch/twitchAPI-types.ts b/apps/bot/src/lib/twitch/twitchAPI-types.ts index a76a89eda..487d629a0 100644 --- a/apps/bot/src/lib/twitch/twitchAPI-types.ts +++ b/apps/bot/src/lib/twitch/twitchAPI-types.ts @@ -1,4 +1,4 @@ -import type { TwitchAPI } from './twitchAPI'; +import type { TwitchAPI } from './twitchAPI.js'; export interface TwitchToken { access_token: string; diff --git a/apps/bot/src/lib/twitch/twitchAPI.ts b/apps/bot/src/lib/twitch/twitchAPI.ts index 48ccbb158..42db53dbe 100644 --- a/apps/bot/src/lib/twitch/twitchAPI.ts +++ b/apps/bot/src/lib/twitch/twitchAPI.ts @@ -8,7 +8,7 @@ import type { TwitchStreamsResponse, TwitchGame, TwitchGamesResponse -} from './twitchAPI-types'; +} from './twitchAPI-types.js'; // Max Number per call is 100 entries const chunk_size = 100; @@ -96,7 +96,7 @@ export class TwitchAPI { if (!ids.length && !logins.length) throw new Error(`Empty array in the "ids" or "logins" property`); - const numTotal: number = ids.length ?? 0 + logins.length ?? 0; + const numTotal: number = (ids.length ?? 0) + (logins.length ?? 0); let offset: number = 0; for (let i = 0; i < numTotal; i += chunk_size) { @@ -294,7 +294,8 @@ export class TwitchAPI { `Empty array in the "user_ids" or "user_logins" property` ); - const numTotal: number = user_ids.length ?? 0 + user_logins.length ?? 0; + const numTotal: number = + (user_ids.length ?? 0) + (user_logins.length ?? 0); let offset: number = 0; for (let i = 0; i < numTotal; i += chunk_size) { diff --git a/apps/bot/src/listeners/commandDenied.ts b/apps/bot/src/listeners/commandDenied.ts index 1b3893178..4854b04db 100644 --- a/apps/bot/src/listeners/commandDenied.ts +++ b/apps/bot/src/listeners/commandDenied.ts @@ -14,10 +14,16 @@ export class CommandDeniedListener extends Listener { { context, message: content }: UserError, { interaction }: ChatInputCommandDeniedPayload ): Promise { - await interaction.reply({ - ephemeral: true, - content: content - }); + if (interaction.deferred || interaction.replied) { + await interaction.editReply({ content }).catch(() => {}); + } else { + await interaction + .reply({ + ephemeral: true, + content: content + }) + .catch(() => {}); + } return; } diff --git a/apps/bot/src/listeners/guild/guildCreate.ts b/apps/bot/src/listeners/guild/guildCreate.ts index a3da05fc9..765e27cae 100644 --- a/apps/bot/src/listeners/guild/guildCreate.ts +++ b/apps/bot/src/listeners/guild/guildCreate.ts @@ -1,7 +1,7 @@ import { ApplyOptions } from '@sapphire/decorators'; import { Listener, type ListenerOptions } from '@sapphire/framework'; import type { Guild } from 'discord.js'; -import { trpcNode } from '../../trpc'; +import { dataService } from '../../dataService.js'; @ApplyOptions({ name: 'guildCreate' @@ -10,12 +10,12 @@ export class GuildCreateListener extends Listener { public override async run(guild: Guild): Promise { const owner = await guild.fetchOwner(); - await trpcNode.user.create.mutate({ + await dataService.user.create({ id: owner.id, name: owner.user.username }); - await trpcNode.guild.create.mutate({ + await dataService.guild.create({ id: guild.id, name: guild.name, ownerId: owner.id diff --git a/apps/bot/src/listeners/guild/guildDelete.ts b/apps/bot/src/listeners/guild/guildDelete.ts index cf90a64fe..6d3f6b3b7 100644 --- a/apps/bot/src/listeners/guild/guildDelete.ts +++ b/apps/bot/src/listeners/guild/guildDelete.ts @@ -1,14 +1,14 @@ import { ApplyOptions } from '@sapphire/decorators'; import { Listener, type ListenerOptions } from '@sapphire/framework'; import type { Guild } from 'discord.js'; -import { trpcNode } from '../../trpc'; +import { dataService } from '../../dataService.js'; @ApplyOptions({ name: 'guildDelete' }) export class GuildDeleteListener extends Listener { public override async run(guild: Guild): Promise { - await trpcNode.guild.delete.mutate({ + await dataService.guild.delete({ id: guild.id }); } diff --git a/apps/bot/src/listeners/guild/guildMemberAdd.ts b/apps/bot/src/listeners/guild/guildMemberAdd.ts index 9d631084d..2f3d61c6b 100644 --- a/apps/bot/src/listeners/guild/guildMemberAdd.ts +++ b/apps/bot/src/listeners/guild/guildMemberAdd.ts @@ -1,15 +1,14 @@ -//import type { Guild } from '@prisma/client'; import { ApplyOptions } from '@sapphire/decorators'; import { Listener, type ListenerOptions } from '@sapphire/framework'; import type { GuildMember, TextChannel } from 'discord.js'; -import { trpcNode } from '../../trpc'; +import { dataService } from '../../dataService.js'; @ApplyOptions({ name: 'guildMemberAdd' }) export class GuildMemberListener extends Listener { public override async run(member: GuildMember): Promise { - const guildQuery = await trpcNode.guild.getGuild.query({ + const guildQuery = await dataService.guild.getGuild({ id: member.guild.id }); @@ -18,21 +17,34 @@ export class GuildMemberListener extends Listener { const { welcomeMessage, welcomeMessageEnabled, welcomeMessageChannel } = guildQuery.guild; - if ( - !welcomeMessageEnabled || - !welcomeMessage || - !welcomeMessage.length || - !welcomeMessageChannel - ) { + if (!welcomeMessageEnabled || !welcomeMessageChannel) { return; } - const channel = (await member.guild.channels.fetch( - welcomeMessageChannel - )) as TextChannel; + try { + const channel = (await member.guild.channels.fetch( + welcomeMessageChannel + )) as TextChannel; - if (channel) { - await channel.send({ content: `@${member.id} ${welcomeMessage}` }); + if (channel && channel.isTextBased()) { + const rawMessage = + welcomeMessage && welcomeMessage.trim().length > 0 + ? welcomeMessage + : '๐Ÿ‘‹ Welcome {user} to **{server}**! You are member #{memberCount}.'; + + const formatted = rawMessage + .replace(/\{user\}|\{mention\}/g, `<@${member.id}>`) + .replace(/\{username\}/g, member.user.username) + .replace(/\{server\}|\{guild\}/g, member.guild.name) + .replace( + /\{memberCount\}|\{position\}/g, + String(member.guild.memberCount || 1) + ); + + await channel.send({ content: formatted }); + } + } catch (error) { + this.container.logger.error('Failed to send welcome message: ', error); } } } diff --git a/apps/bot/src/listeners/interaction/ticketButtonListener.ts b/apps/bot/src/listeners/interaction/ticketButtonListener.ts new file mode 100644 index 000000000..f7523de6a --- /dev/null +++ b/apps/bot/src/listeners/interaction/ticketButtonListener.ts @@ -0,0 +1,327 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Events, Listener, type ListenerOptions } from '@sapphire/framework'; +import { + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ChannelType, + EmbedBuilder, + Interaction, + TextChannel, + ThreadAutoArchiveDuration, + ThreadChannel +} from 'discord.js'; +import { dataService } from '../../dataService.js'; + +export const DEFAULT_TICKET_MESSAGE = + '๐Ÿ‘‹ Hello {user}, thank you for contacting support in **{server}**!\n\n' + + 'A support representative or moderator will be with you shortly. In the meantime, please provide as much detail as possible:\n' + + 'โ€ข A clear description of your question, inquiry, or issue\n' + + 'โ€ข Any relevant screenshots, error messages, or transaction IDs\n' + + 'โ€ข Any steps you have already tried to resolve the problem\n\n' + + 'To close this ticket once your inquiry is resolved, click the **Close Ticket** button below.'; + +@ApplyOptions({ + event: Events.InteractionCreate +}) +export class TicketButtonListener extends Listener { + public override async run(interaction: Interaction): Promise { + if (!interaction.isButton()) return; + const buttonInteraction = interaction as ButtonInteraction; + + if (buttonInteraction.customId === 'ticket_create') { + await this.handleCreateTicket(buttonInteraction); + } else if (buttonInteraction.customId === 'ticket_close') { + await this.handleCloseTicket(buttonInteraction); + } + } + + private async handleCreateTicket(interaction: ButtonInteraction) { + const guild = interaction.guild; + const user = interaction.user; + const channel = interaction.channel as TextChannel; + + if (!guild || !channel) { + return await interaction.reply({ + content: ':x: This button can only be used in a server channel.', + ephemeral: true + }); + } + + await interaction.deferReply({ ephemeral: true }); + + try { + const config = await dataService.tickets.getConfig({ + guildId: guild.id + }); + + if (!config.guild?.ticketEnabled) { + return await interaction.editReply({ + content: + ':warning: The ticket system is currently disabled for this server.' + }); + } + + // Clean username for thread name + const sanitizedUsername = user.username + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '') + .slice(0, 20); + const threadName = `๐ŸŽซใƒปticket-${sanitizedUsername || user.id.slice(0, 6)}`; + + // Create a private thread if bot/server supports it, otherwise public thread + let thread: ThreadChannel; + try { + thread = await channel.threads.create({ + name: threadName, + autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, + type: ChannelType.PrivateThread, + reason: `Support ticket created by ${user.tag}` + }); + } catch { + // Fallback to public thread if server does not support private threads + thread = await channel.threads.create({ + name: threadName, + autoArchiveDuration: ThreadAutoArchiveDuration.OneWeek, + type: ChannelType.PublicThread, + reason: `Support ticket created by ${user.tag}` + }); + } + + // Add member to the thread + await thread.members.add(user.id).catch(() => {}); + + // Add staff / ticket manager role members to the thread if configured + const ticketRoleId = config.guild?.ticketRoleId; + if (ticketRoleId) { + try { + const role = + guild.roles.cache.get(ticketRoleId) || + (await guild.roles.fetch(ticketRoleId).catch(() => null)); + if (role) { + for (const [memberId] of role.members) { + await thread.members.add(memberId).catch(() => {}); + } + } + } catch (roleErr) { + this.container.logger.error( + 'Failed to add ticket role members to thread:', + roleErr + ); + } + } + + // Register in database + await dataService.tickets.createTicket({ + guildId: guild.id, + threadId: thread.id, + creatorId: user.id + }); + + // Format welcome message + const customMessage = config.guild?.ticketMessage; + const rawTemplate = + customMessage && customMessage.trim().length > 0 + ? customMessage + : DEFAULT_TICKET_MESSAGE; + + const formattedMessage = rawTemplate + .replace(/\{user\}|\{mention\}/g, `<@${user.id}>`) + .replace(/\{username\}/g, user.username) + .replace(/\{server\}|\{guild\}/g, guild.name); + + const ticketEmbed = new EmbedBuilder() + .setTitle(`๐ŸŽซ Support Ticket: ${user.username}`) + .setDescription(formattedMessage) + .setColor(0x5865f2) + .addFields( + { + name: '๐Ÿ‘ค Opened By', + value: `${user.tag} (<@${user.id}>)`, + inline: true + }, + { + name: '๐Ÿ•’ Opened At', + value: ``, + inline: true + } + ); + + if (ticketRoleId) { + ticketEmbed.addFields({ + name: '๐Ÿ›ก๏ธ Support Role', + value: `<@&${ticketRoleId}>`, + inline: true + }); + } + + ticketEmbed + .setFooter({ + text: `Ticket ID: ${thread.id} โ€ข Master-Bot Support`, + iconURL: guild.iconURL() || undefined + }) + .setTimestamp(); + + const closeButton = new ButtonBuilder() + .setCustomId('ticket_close') + .setLabel('Close Ticket') + .setStyle(ButtonStyle.Danger) + .setEmoji('๐Ÿ”’'); + + const actionRow = new ActionRowBuilder().addComponents( + closeButton + ); + + const mentionContent = ticketRoleId + ? `<@${user.id}> <@&${ticketRoleId}>` + : `<@${user.id}>`; + + await thread.send({ + content: mentionContent, + embeds: [ticketEmbed], + components: [actionRow] + }); + + return await interaction.editReply({ + content: `:white_check_mark: Your support ticket has been created: <#${thread.id}>` + }); + } catch (error) { + this.container.logger.error('Failed to create ticket thread:', error); + return await interaction.editReply({ + content: + ':x: An error occurred while creating your ticket thread. Please make sure the bot has permission to create and manage threads.' + }); + } + } + + private async handleCloseTicket(interaction: ButtonInteraction) { + const thread = interaction.channel; + const guild = interaction.guild; + + if (!thread || !thread.isThread() || !guild) { + return await interaction.reply({ + content: ':x: This button can only be used inside a ticket thread.', + ephemeral: true + }); + } + + await interaction.deferReply(); + + try { + // Record closed in database + await dataService.tickets.closeTicket({ + threadId: thread.id + }) + .catch(() => {}); + + // Query guild ticket configuration to check transcript channel + const ticketConfig = await dataService.tickets.getConfig({ + guildId: guild.id + }) + .catch(() => null); + + const transcriptChannelId = ticketConfig?.guild?.ticketTranscriptChannel; + + if (transcriptChannelId) { + try { + const transcriptChannel = (await guild.channels.fetch( + transcriptChannelId + )) as TextChannel; + + if (transcriptChannel) { + // Fetch thread messages for transcript + const messages = await thread.messages.fetch({ limit: 100 }); + const sortedMessages = Array.from(messages.values()).sort( + (a, b) => a.createdTimestamp - b.createdTimestamp + ); + + let transcriptContent = `====================================================\n`; + transcriptContent += `TICKET TRANSCRIPT: ${thread.name} (${thread.id})\n`; + transcriptContent += `Server: ${guild.name} (${guild.id})\n`; + transcriptContent += `Closed By: ${interaction.user.tag} (${interaction.user.id})\n`; + transcriptContent += `Timestamp: ${new Date().toISOString()}\n`; + transcriptContent += `====================================================\n\n`; + + for (const msg of sortedMessages) { + const timestamp = new Date(msg.createdTimestamp) + .toISOString() + .replace('T', ' ') + .slice(0, 19); + const author = `${msg.author.tag} (${msg.author.id})`; + const text = + msg.cleanContent || + (msg.embeds.length ? '[Embed content]' : '[No text content]'); + transcriptContent += `[${timestamp}] ${author}:\n${text}\n\n`; + } + + const buffer = Buffer.from(transcriptContent, 'utf-8'); + const attachment = new AttachmentBuilder(buffer, { + name: `transcript-${thread.id}.txt` + }); + + const transcriptEmbed = new EmbedBuilder() + .setTitle(`๐Ÿ“œ Ticket Transcript: ${thread.name}`) + .setColor(0x3498db) + .addFields( + { + name: '๐ŸŽซ Thread', + value: `${thread.name} (\`${thread.id}\`)`, + inline: true + }, + { + name: '๐Ÿ›ก๏ธ Closed By', + value: `${interaction.user.tag} (<@${interaction.user.id}>)`, + inline: true + }, + { + name: '๐Ÿ’ฌ Total Messages', + value: `${sortedMessages.length}`, + inline: true + } + ) + .setFooter({ + text: `Master-Bot Ticket Transcripts โ€ข ${guild.name}`, + iconURL: guild.iconURL() || undefined + }) + .setTimestamp(); + + await transcriptChannel.send({ + embeds: [transcriptEmbed], + files: [attachment] + }); + } + } catch (transcriptError) { + this.container.logger.error( + 'Failed to send ticket transcript:', + transcriptError + ); + } + } + + const closeEmbed = new EmbedBuilder() + .setTitle('๐Ÿ”’ Ticket Closed') + .setDescription( + `This ticket was closed by ${interaction.user.tag} (<@${interaction.user.id}>).\n\n` + + 'This thread will now be locked and archived. If you require further assistance, please open a new ticket from the support channel.' + ) + .setColor(0x95a5a6) + .setTimestamp(); + + await interaction.editReply({ embeds: [closeEmbed] }); + + // Lock and archive the thread + await thread.setLocked(true, `Ticket closed by ${interaction.user.tag}`); + return await thread.setArchived( + true, + `Ticket closed by ${interaction.user.tag}` + ); + } catch (error) { + this.container.logger.error('Failed to close ticket thread:', error); + return await interaction.editReply({ + content: ':x: An error occurred while closing this ticket thread.' + }); + } + } +} diff --git a/apps/bot/src/listeners/music/musicFinish.ts b/apps/bot/src/listeners/music/musicFinish.ts index 0e009fbdc..9f12f727a 100644 --- a/apps/bot/src/listeners/music/musicFinish.ts +++ b/apps/bot/src/listeners/music/musicFinish.ts @@ -1,7 +1,7 @@ import { ApplyOptions } from '@sapphire/decorators'; import { Listener, type ListenerOptions, container } from '@sapphire/framework'; -import { deletePlayerEmbed } from '../../lib/music/buttonsCollector'; -import type { Queue } from '../../lib/music/classes/Queue'; +import { deletePlayerEmbed } from '../../lib/music/buttonsCollector.js'; +import type { Queue } from '../../lib/music/classes/Queue.js'; // import { inactivityTime } from '../../lib/music/handleOptions'; @ApplyOptions({ diff --git a/apps/bot/src/listeners/music/musicSongPlay.ts b/apps/bot/src/listeners/music/musicSongPlay.ts index 6edcf52fe..7fe9ccda8 100644 --- a/apps/bot/src/listeners/music/musicSongPlay.ts +++ b/apps/bot/src/listeners/music/musicSongPlay.ts @@ -1,7 +1,7 @@ import { ApplyOptions } from '@sapphire/decorators'; import { container, Listener, type ListenerOptions } from '@sapphire/framework'; -import type { Queue } from '../../lib/music/classes/Queue'; -import type { Song } from '../../lib/music/classes/Song'; +import type { Queue } from '../../lib/music/classes/Queue.js'; +import type { Song } from '../../lib/music/classes/Song.js'; @ApplyOptions({ name: 'musicSongPlay' diff --git a/apps/bot/src/listeners/music/musicSongPlayMessage.ts b/apps/bot/src/listeners/music/musicSongPlayMessage.ts index 19a9cb83d..bb279a737 100644 --- a/apps/bot/src/listeners/music/musicSongPlayMessage.ts +++ b/apps/bot/src/listeners/music/musicSongPlayMessage.ts @@ -1,10 +1,10 @@ import { ApplyOptions } from '@sapphire/decorators'; import { container, Listener, type ListenerOptions } from '@sapphire/framework'; import type { TextChannel } from 'discord.js'; -import { embedButtons } from '../../lib/music/buttonHandler'; -import { NowPlayingEmbed } from '../../lib/music/nowPlayingEmbed'; -import type { Song } from '../../lib/music/classes/Song'; -import { manageStageChannel } from '../../lib/music/channelHandler'; +import { embedButtons } from '../../lib/music/buttonHandler.js'; +import { NowPlayingEmbed } from '../../lib/music/nowPlayingEmbed.js'; +import type { Song } from '../../lib/music/classes/Song.js'; +import { manageStageChannel } from '../../lib/music/channelHandler.js'; @ApplyOptions({ name: 'musicSongPlayMessage' @@ -16,9 +16,9 @@ export class MusicSongPlayMessageListener extends Listener { const tracks = await queue.tracks(); const NowPlaying = new NowPlayingEmbed( track, - queue.player.accuratePosition, + queue.player?.position ?? 0, track.length ?? 0, - queue.player.volume, + queue.player?.volume ?? 100, tracks, tracks.at(-1), queue.paused diff --git a/apps/bot/src/listeners/music/musicSongSkipNotify.ts b/apps/bot/src/listeners/music/musicSongSkipNotify.ts index a8c9ba534..4749c1433 100644 --- a/apps/bot/src/listeners/music/musicSongSkipNotify.ts +++ b/apps/bot/src/listeners/music/musicSongSkipNotify.ts @@ -1,4 +1,4 @@ -import type { Song } from '../../lib/music/classes/Song'; +import type { Song } from '../../lib/music/classes/Song.js'; import { ApplyOptions } from '@sapphire/decorators'; import { Listener, type ListenerOptions } from '@sapphire/framework'; import { ChatInputCommandInteraction } from 'discord.js'; @@ -11,7 +11,10 @@ export class MusicSongSkipNotifyListener extends Listener { interaction: ChatInputCommandInteraction, track: Song ): Promise { - if (!track) return; - await interaction.reply({ content: `${track.title} has been skipped.` }); + if (interaction.replied || interaction.deferred) return; + const message = track + ? `:white_check_mark: Skipped [**${track.title}**](<${track.uri}>).` + : ':white_check_mark: Skipped the current track.'; + await interaction.reply({ content: message }); } } diff --git a/apps/bot/src/listeners/tempchannels/voiceStateUpdate.ts b/apps/bot/src/listeners/tempchannels/voiceStateUpdate.ts index 6d1703df3..62e871c74 100644 --- a/apps/bot/src/listeners/tempchannels/voiceStateUpdate.ts +++ b/apps/bot/src/listeners/tempchannels/voiceStateUpdate.ts @@ -1,7 +1,7 @@ import { ApplyOptions } from '@sapphire/decorators'; import { Listener, ListenerOptions } from '@sapphire/framework'; import type { VoiceChannel, VoiceState } from 'discord.js'; -import { trpcNode } from '../../trpc'; +import { dataService } from '../../dataService.js'; import { ChannelType } from 'discord.js'; @ApplyOptions({ @@ -12,7 +12,7 @@ export class VoiceStateUpdateListener extends Listener { oldState: VoiceState, newState: VoiceState ): Promise { - const { guild: guildDB } = await trpcNode.guild.getGuild.query({ + const { guild: guildDB } = await dataService.guild.getGuild({ id: newState.guild.id }); @@ -21,7 +21,7 @@ export class VoiceStateUpdateListener extends Listener { if (!newState.member) return; // should not happen but just in case if (newState.channelId === guildDB?.hubChannel && guildDB.hub) { - const { tempChannel } = await trpcNode.hub.getTempChannel.query({ + const { tempChannel } = await dataService.hub.getTempChannel({ guildId: newState.guild.id, ownerId: newState.member.id }); @@ -52,7 +52,7 @@ export class VoiceStateUpdateListener extends Listener { ] }); - await trpcNode.hub.createTempChannel.mutate({ + await dataService.hub.createTempChannel({ guildId: newState.guild.id, ownerId: newState.member.id, channelId: channel.id @@ -60,7 +60,7 @@ export class VoiceStateUpdateListener extends Listener { await newState.member.voice.setChannel(channel); } else { - const { tempChannel } = await trpcNode.hub.getTempChannel.query({ + const { tempChannel } = await dataService.hub.getTempChannel({ guildId: newState.guild.id, ownerId: newState.member.id }); @@ -75,7 +75,7 @@ export class VoiceStateUpdateListener extends Listener { Promise.all([ channel.delete(), - trpcNode.hub.deleteTempChannel.mutate({ + dataService.hub.deleteTempChannel({ channelId: tempChannel.id }) ]); @@ -88,7 +88,7 @@ export class VoiceStateUpdateListener extends Listener { } async function deleteChannel(state: VoiceState) { - const { tempChannel } = await trpcNode.hub.getTempChannel.query({ + const { tempChannel } = await dataService.hub.getTempChannel({ guildId: state.guild.id, ownerId: state.member!.id }); @@ -96,7 +96,7 @@ async function deleteChannel(state: VoiceState) { if (tempChannel) { Promise.all([ state.channel?.delete(), - trpcNode.hub.deleteTempChannel.mutate({ + dataService.hub.deleteTempChannel({ channelId: tempChannel.id }) ]); diff --git a/apps/bot/src/preconditions/isCommandDisabled.ts b/apps/bot/src/preconditions/isCommandDisabled.ts index e33016911..1507f5fd9 100644 --- a/apps/bot/src/preconditions/isCommandDisabled.ts +++ b/apps/bot/src/preconditions/isCommandDisabled.ts @@ -5,7 +5,59 @@ import { PreconditionOptions } from '@sapphire/framework'; import { ChatInputCommandInteraction } from 'discord.js'; -import { trpcNode } from '../trpc'; +import { dataService } from '../dataService.js'; + +import { container } from '@sapphire/framework'; +import { + isGifsEnabled, + isIgdbEnabled, + isLavalinkEnabled, + isNewsEnabled, + isTwitchEnabled +} from '../env.js'; + +interface DisabledCacheEntry { + commands: string[]; + expiresAt: number; +} + +const disabledCommandsCache = new Map(); + +/** + * Checks whether a command or category is globally disabled dynamically + * by querying the command's category in Sapphire against feature toggles. + */ +export function isCommandNameGloballyDisabled( + commandOrCategoryName: string +): boolean { + const lavaEnabled = isLavalinkEnabled(); + const gifsEnabled = isGifsEnabled(); + const twitchEnabled = isTwitchEnabled(); + const newsEnabled = isNewsEnabled(); + // IGDB utilizes Twitch API credentials โ€” respects IGDB_ENABLED if set, otherwise follows TWITCH_ENABLED + const igdbEnabled = isIgdbEnabled(); + + const name = commandOrCategoryName.toLowerCase(); + + // 1. Direct Category Checks + if (!lavaEnabled && name === 'music') return true; + if (!gifsEnabled && name === 'gifs') return true; + if (!twitchEnabled && name === 'twitch') return true; + + // 2. Dynamic Command Category Lookup + const cmd = container.stores.get('commands')?.get(name); + if (cmd) { + const category = cmd.category?.toLowerCase() || ''; + if (!lavaEnabled && category === 'music') return true; + if (!gifsEnabled && category === 'gifs') return true; + if (!twitchEnabled && category === 'twitch') return true; + if (!newsEnabled && cmd.name === 'news') return true; + if ((!igdbEnabled || !twitchEnabled) && cmd.name === 'game-search') + return true; + } + + return false; +} @ApplyOptions({ name: 'isCommandDisabled' @@ -16,20 +68,74 @@ export class IsCommandDisabledPrecondition extends Precondition { ): AsyncPreconditionResult { const commandID = interaction.commandId; const guildID = interaction.guildId as string; - // Most likly a DM - if (!interaction.guildId && interaction.user.id) { - return this.ok(); - } - const data = await trpcNode.command.getDisabledCommands.query({ - guildId: guildID - }); - if (data.disabledCommands.includes(commandID)) { + // Check global disable state via dynamic feature toggles + if (isCommandNameGloballyDisabled(interaction.commandName)) { + const cmd = container.stores + .get('commands') + ?.get(interaction.commandName); + const category = cmd?.category?.toLowerCase() || ''; + let featureName = 'This feature'; + if (category === 'music' || interaction.commandName === 'music') { + featureName = 'Music & Audio commands'; + } else if (category === 'gifs' || interaction.commandName === 'gifs') { + featureName = 'GIF commands'; + } else if ( + category === 'twitch' || + interaction.commandName === 'twitch' + ) { + featureName = 'Twitch commands'; + } else if (interaction.commandName === 'game-search') { + featureName = 'Game search (IGDB)'; + } else if (interaction.commandName === 'news') { + featureName = 'News commands'; + } + return this.error({ - message: 'This command is disabled' + message: `:warning: ${featureName} are currently disabled in configuration.` }); } + // Most likely a DM + if (!guildID) { + return this.ok(); + } + + try { + const cached = disabledCommandsCache.get(guildID); + let disabledCommands: string[]; + + if (cached && cached.expiresAt > Date.now()) { + disabledCommands = cached.commands; + } else { + const queryPromise = dataService.command.getDisabledCommands({ + guildId: guildID + }); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('Precondition timeout')), 300) + ); + + const data = (await Promise.race([ + queryPromise, + timeoutPromise + ])) as any; + disabledCommands = data?.disabledCommands || []; + disabledCommandsCache.set(guildID, { + commands: disabledCommands, + expiresAt: Date.now() + 60_000 + }); + } + + if (disabledCommands.includes(commandID)) { + return this.error({ + message: 'This command is disabled' + }); + } + } catch { + // On timeout or tRPC error, allow command to proceed to ensure Discord gets response within 3s + return this.ok(); + } + return this.ok(); } } diff --git a/apps/bot/src/preconditions/playerIsPlaying.ts b/apps/bot/src/preconditions/playerIsPlaying.ts index f8c389957..f3d3677e6 100644 --- a/apps/bot/src/preconditions/playerIsPlaying.ts +++ b/apps/bot/src/preconditions/playerIsPlaying.ts @@ -15,7 +15,7 @@ export class PlayerIsPlaying extends Precondition { interaction: ChatInputCommandInteraction ): PreconditionResult { const { client } = container; - const player = client.music.players.get(interaction.guildId as string); + const player = client.music.getPlayer(interaction.guildId as string); if (!player) { return this.error({ message: 'There is nothing playing at the moment!' }); diff --git a/apps/bot/src/preconditions/playlistExists.ts b/apps/bot/src/preconditions/playlistExists.ts index 5ec27fcf4..aa3615e46 100644 --- a/apps/bot/src/preconditions/playlistExists.ts +++ b/apps/bot/src/preconditions/playlistExists.ts @@ -5,7 +5,7 @@ import { PreconditionOptions } from '@sapphire/framework'; import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; -import { trpcNode } from '../trpc'; +import { dataService } from '../dataService.js'; @ApplyOptions({ name: 'playlistExists' @@ -18,7 +18,7 @@ export class PlaylistExists extends Precondition { const guildMember = interaction.member as GuildMember; - const playlist = await trpcNode.playlist.getPlaylist.query({ + const playlist = await dataService.playlist.getPlaylist({ name: playlistName, userId: guildMember.id }); @@ -27,7 +27,7 @@ export class PlaylistExists extends Precondition { ? this.ok() : this.error({ message: `You have no playlist named **${playlistName}**` - }); + }); } } diff --git a/apps/bot/src/preconditions/playlistNotDuplicate.ts b/apps/bot/src/preconditions/playlistNotDuplicate.ts index 8a1159d34..e63f25f21 100644 --- a/apps/bot/src/preconditions/playlistNotDuplicate.ts +++ b/apps/bot/src/preconditions/playlistNotDuplicate.ts @@ -5,7 +5,7 @@ import { PreconditionOptions } from '@sapphire/framework'; import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; -import { trpcNode } from '../trpc'; +import { dataService } from '../dataService.js'; @ApplyOptions({ name: 'playlistNotDuplicate' @@ -19,7 +19,7 @@ export class PlaylistNotDuplicate extends Precondition { const guildMember = interaction.member as GuildMember; try { - const playlist = await trpcNode.playlist.getPlaylist.query({ + const playlist = await dataService.playlist.getPlaylist({ name: playlistName, userId: guildMember.id }); diff --git a/apps/bot/src/preconditions/userInDB.ts b/apps/bot/src/preconditions/userInDB.ts index 1f31f23f6..12205bce1 100644 --- a/apps/bot/src/preconditions/userInDB.ts +++ b/apps/bot/src/preconditions/userInDB.ts @@ -5,8 +5,8 @@ import { PreconditionOptions } from '@sapphire/framework'; import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; -import { trpcNode } from '../trpc'; -import Logger from '../lib/logger'; +import { dataService } from '../dataService.js'; +import Logger from '../lib/logger.js'; @ApplyOptions({ name: 'userInDB' @@ -18,7 +18,7 @@ export class UserInDB extends Precondition { const guildMember = interaction.member as GuildMember; try { - const user = await trpcNode.user.create.mutate({ + const user = await dataService.user.create({ id: guildMember.id, name: guildMember.user.username }); diff --git a/apps/bot/src/server.ts b/apps/bot/src/server.ts new file mode 100644 index 000000000..b9dd6d9af --- /dev/null +++ b/apps/bot/src/server.ts @@ -0,0 +1,132 @@ +import http from 'node:http'; +import { URL } from 'node:url'; +import pc from 'picocolors'; +import { + routeDashboardRequest, + setDashboardContext, + type DashboardContext, + type DashboardGuildInfo, + type DashboardBotState +} from '@master-bot/dashboard'; +import { getCallbackUrl, getPort, normalizeCallbackBaseUrl, getOwnerId } from './env.js'; +import Logger from './lib/logger.js'; +import { container } from '@sapphire/framework'; + +export interface BotServerOptions { + callbackUrl?: string; +} + +export class BotCallbackServer { + private server: http.Server | null = null; + private port: number; + private baseUrl: string; + + constructor(options: BotServerOptions = {}) { + this.baseUrl = normalizeCallbackBaseUrl(options.callbackUrl || getCallbackUrl()); + this.port = getPort(); + } + + public start(): Promise { + this.registerContext(); + + return new Promise((resolve, reject) => { + this.server = http.createServer(async (req, res) => { + // First try routing to Dashboard, NextAuth & API endpoints + const dashboardHandled = await routeDashboardRequest(req, res, this.baseUrl); + if (dashboardHandled) return; + + const reqUrl = req.url || '/'; + const parsed = new URL(reqUrl, `http://localhost:${this.port}`); + + if (parsed.pathname === '/api/health' || parsed.pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + status: 'ok', + service: 'master-bot', + timestamp: new Date().toISOString() + }) + ); + return; + } + + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('Master-Bot Callback Server is running.'); + }); + + this.server.on('error', (err) => { + Logger.warn(`Callback server warning: ${err.message}`); + resolve(); // Continue even if port is already occupied + }); + + this.server.listen(this.port, '0.0.0.0', () => { + const callbackEndpoint = `${this.baseUrl.replace(/\/$/, '')}/api/auth/callback/discord`; + const dashboardEndpoint = `${this.baseUrl.replace(/\/$/, '')}/dashboard`; + Logger.info(`OAuth2 Callback Server listening at: ${pc.cyan(callbackEndpoint)}`); + Logger.info(`Discord Bot Dashboard running at: ${pc.bold(pc.cyan(dashboardEndpoint))}`); + resolve(); + }); + }); + } + + public stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => resolve()); + } else { + resolve(); + } + }); + } + + /** + * Injects the runtime context the dashboard needs to reach the live client + * and the shared sqlite database โ€” HELIX keeps these in one package; + * Master-Bot passes them across the @master-bot/bot / @master-bot/dashboard + * package boundary. + */ + private registerContext(): void { + const ctx: DashboardContext = { + getBotState: (): DashboardBotState => { + const client = container.client; + const isReady = client?.isReady() ?? false; + const gatewayLatency = client?.ws.ping ?? -1; + const guilds: DashboardGuildInfo[] = client + ? [...client.guilds.cache.values()].map(g => ({ + id: g.id, + name: g.name, + icon: g.icon, + ownerId: g.ownerId, + memberCount: g.memberCount, + channelMap: {}, + settings: {} + })) + : []; + return { isReady, gatewayLatency, guilds }; + }, + sendChannelMessage: async (channelId, message) => { + try { + const client = container.client; + if (!client) return false; + const ch = await client.channels.fetch(channelId); + if (ch && ch.isTextBased() && !ch.isDMBased()) { + await (ch as any).send(message); + return true; + } + return false; + } catch { + return false; + } + }, + getGatewayLatency: () => container.client?.ws.ping ?? -1, + isOwner: (userId?: string) => { + if (!userId) return false; + const ownerId = getOwnerId(); + if (ownerId) return userId === ownerId; + const appOwner = container.client?.application?.owner; + return appOwner ? userId === appOwner.id : false; + } + }; + setDashboardContext(ctx); + } +} \ No newline at end of file diff --git a/apps/bot/src/trpc.ts b/apps/bot/src/trpc.ts deleted file mode 100644 index 1d6f10483..000000000 --- a/apps/bot/src/trpc.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { AppRouter } from '@master-bot/api/index'; -import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; -import superjson from 'superjson'; -// @ts-ignore -import * as trpcServer from '@trpc/server'; -// @ts-ignore -import * as PrismaClient from '@prisma/client'; -const _importDynamic = new Function('modulePath', 'return import(modulePath)'); - -const fetch = async function (...args: any) { - const { default: fetch } = await _importDynamic('node-fetch'); - return fetch(...args); -}; - -const globalAny = global as any; -globalAny.fetch = fetch; - -export const trpcNode = createTRPCProxyClient({ - links: [ - httpBatchLink({ - url: 'http://localhost:3000/api/trpc' - }) - ], - transformer: superjson -}); diff --git a/apps/bot/tsconfig.json b/apps/bot/tsconfig.json index b08cfc082..3addc888e 100644 --- a/apps/bot/tsconfig.json +++ b/apps/bot/tsconfig.json @@ -1,6 +1,9 @@ { "extends": "@sapphire/ts-config", "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", "rootDir": "src", "experimentalDecorators": true, "incremental": true, @@ -15,6 +18,6 @@ "esModuleInterop": true, "noImplicitAny": false }, - "include": ["src", "scripts", "src/env.ts"], + "include": ["src", "scripts"], "exclude": ["node_modules"] -} +} \ No newline at end of file diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md deleted file mode 100644 index cc4052672..000000000 --- a/apps/dashboard/README.md +++ /dev/null @@ -1,28 +0,0 @@ -# Create T3 App - -This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. - -## What's next? How do I make an app with this? - -We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. - -If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. - -- [Next.js](https://nextjs.org) -- [NextAuth.js](https://next-auth.js.org) -- [Prisma](https://prisma.io) -- [Tailwind CSS](https://tailwindcss.com) -- [tRPC](https://trpc.io) - -## Learn More - -To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: - -- [Documentation](https://create.t3.gg/) -- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) โ€” Check out these awesome tutorials - -You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) โ€” your feedback and contributions are welcome! - -## How do I deploy this? - -Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. diff --git a/apps/dashboard/components.json b/apps/dashboard/components.json deleted file mode 100644 index 684d9eff8..000000000 --- a/apps/dashboard/components.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "tailwind.config.js", - "css": "src/app/styles/globals.css", - "baseColor": "slate", - "cssVariables": true - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils" - } -} diff --git a/apps/dashboard/next-env.d.ts b/apps/dashboard/next-env.d.ts deleted file mode 100644 index 4f11a03dc..000000000 --- a/apps/dashboard/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs deleted file mode 100644 index a793ff12c..000000000 --- a/apps/dashboard/next.config.mjs +++ /dev/null @@ -1,18 +0,0 @@ -// Importing env files here to validate on build -import './src/env.mjs'; -import '@master-bot/auth/env.mjs'; - -/** @type {import("next").NextConfig} */ -const config = { - reactStrictMode: true, - /** Enables hot reloading for local packages without a build step */ - transpilePackages: ['@master-bot/api', '@master-bot/auth', '@master-bot/db'], - /** We already do linting and typechecking as separate tasks in CI */ - eslint: { ignoreDuringBuilds: true }, - typescript: { ignoreBuildErrors: true }, - images: { - domains: ['cdn.discordapp.com'] - } -}; - -export default config; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index f362b105e..0b4e56e87 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -1,66 +1,25 @@ { "name": "@master-bot/dashboard", - "version": "0.1.0", + "version": "1.0.0", "private": true, + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "license": "ISC", "scripts": { - "build": "pnpm with-env next build", - "clean": "git clean -xdf .next .turbo node_modules", - "dev": "pnpm with-env next dev", - "lint": "dotenv -v SKIP_ENV_VALIDATION=1 next lint", - "lint:fix": "pnpm lint --fix", - "start": "pnpm with-env next start", + "build": "tsc", "type-check": "tsc --noEmit", - "with-env": "dotenv -e ../../.env --" + "dev": "tsc --watch" + }, + "engines": { + "node": ">=22.0.0" }, "dependencies": { - "@master-bot/api": "^0.1.0", - "@master-bot/auth": "^0.1.0", "@master-bot/db": "^0.1.0", - "@radix-ui/react-dropdown-menu": "^2.0.6", - "@radix-ui/react-select": "^2.0.0", - "@radix-ui/react-slot": "^1.0.2", - "@radix-ui/react-switch": "^1.0.3", - "@radix-ui/react-toast": "^1.1.5", - "@t3-oss/env-nextjs": "^0.7.1", - "@tanstack/react-query": "^5.8.4", - "@tanstack/react-query-devtools": "^5.8.4", - "@tanstack/react-query-next-experimental": "5.8.4", - "@trpc/client": "next", - "@trpc/next": "next", - "@trpc/react-query": "next", - "@trpc/server": "next", - "class-variance-authority": "^0.7.0", - "clsx": "^2.0.0", - "discord-api-types": "^0.37.64", - "lucide-react": "^0.292.0", - "next": "^14.0.3", - "next-themes": "^0.2.1", - "react": "18.2.0", - "react-dom": "18.2.0", - "superjson": "1.13.3", - "tailwind-merge": "^2.0.0", - "tailwindcss-animate": "^1.0.7", - "zod": "^3.22.4" + "picocolors": "^1.1.0" }, "devDependencies": { - "@master-bot/eslint-config": "^0.2.0", - "@master-bot/tailwind-config": "^0.1.0", - "@types/node": "^20.9.3", - "@types/react": "^18.2.38", - "@types/react-dom": "^18.2.16", - "autoprefixer": "^10.4.16", - "dotenv-cli": "^7.3.0", - "eslint": "^8.54.0", - "postcss": "^8.4.31", - "tailwindcss": "^3.3.5", - "typescript": "^5.3.2" - }, - "eslintConfig": { - "root": true, - "extends": [ - "@master-bot/eslint-config/base", - "@master-bot/eslint-config/nextjs", - "@master-bot/eslint-config/react" - ] + "@types/node": "^22.5.4", + "typescript": "^5.5.4" } } diff --git a/apps/dashboard/postcss.config.cjs b/apps/dashboard/postcss.config.cjs deleted file mode 100644 index 25fc243a4..000000000 --- a/apps/dashboard/postcss.config.cjs +++ /dev/null @@ -1,2 +0,0 @@ -// @ts-expect-error - No types for postcss -module.exports = require('@master-bot/tailwind-config/postcss'); diff --git a/apps/dashboard/public/favicon.ico b/apps/dashboard/public/favicon.ico deleted file mode 100644 index f0058b404..000000000 Binary files a/apps/dashboard/public/favicon.ico and /dev/null differ diff --git a/apps/dashboard/public/t3-icon.svg b/apps/dashboard/public/t3-icon.svg deleted file mode 100644 index e377165f6..000000000 --- a/apps/dashboard/public/t3-icon.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/apps/dashboard/src/api/bot-actions.ts b/apps/dashboard/src/api/bot-actions.ts new file mode 100644 index 000000000..73357974b --- /dev/null +++ b/apps/dashboard/src/api/bot-actions.ts @@ -0,0 +1,50 @@ +import http from 'node:http'; +import type { DashboardContext } from '../context.js'; + +export async function handleDashboardBotActions( + req: http.IncomingMessage, + res: http.ServerResponse, + action: string, + ctx: DashboardContext +): Promise { + if (action === 'broadcast' && req.method === 'POST') { + let body = ''; + req.on('data', chunk => (body += chunk)); + req.on('end', async () => { + try { + const payload = JSON.parse(body || '{}'); + const channelId = String(payload.channelId || '').trim(); + const message = String(payload.message || '').trim(); + + if (!channelId || !message) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'channelId and message are required' })); + return; + } + + const botState = ctx.getBotState(); + if (!botState.isReady) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Discord bot is not currently connected to gateway' })); + return; + } + + const sent = await ctx.sendChannelMessage(channelId, message); + if (sent) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, channelId, message: 'Message broadcast successfully' })); + } else { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Failed to send message to specified channel. Check permissions and channel ID.' })); + } + } catch (err: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message || 'Broadcast failed' })); + } + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: `Unknown bot action: ${action}` })); +} \ No newline at end of file diff --git a/apps/dashboard/src/api/env.ts b/apps/dashboard/src/api/env.ts new file mode 100644 index 000000000..f35506f55 --- /dev/null +++ b/apps/dashboard/src/api/env.ts @@ -0,0 +1,47 @@ +import { getDashboardBaseUrl, getDashboardUrl, getDashboardPort } from '../auth/config.js'; + +function clean(value: string, stripBotPrefix = false): string { + let cleaned = (value || '').trim(); + if ((cleaned.startsWith('"') && cleaned.endsWith('"')) || (cleaned.startsWith("'") && cleaned.endsWith("'"))) { + cleaned = cleaned.slice(1, -1).trim(); + } + if (stripBotPrefix && cleaned.startsWith('Bot ')) { + cleaned = cleaned.slice(4).trim(); + } + return cleaned; +} + +export function getBotToken(): string { + return clean( + process.env.DISCORD_TOKEN || process.env.DISCORD_BOT_TOKEN || process.env.BOT_TOKEN || process.env.TOKEN || '', + true + ); +} + +export function getClientId(): string { + return clean( + process.env.DISCORD_CLIENT_ID || process.env.CLIENT_ID || process.env.DISCORD_APP_ID || process.env.APPLICATION_ID || process.env.APP_ID || '' + ); +} + +export function getCallbackUrl(): string { + return getDashboardBaseUrl(); +} + +export function getInviteUrl(): string { + const raw = clean(process.env.NEXT_PUBLIC_INVITE_URL || ''); + if (raw) return raw; + const clientId = getClientId(); + if (clientId && clientId !== 'yourclientid') { + return `https://discord.com/oauth2/authorize?client_id=${clientId}&permissions=8&scope=bot%20applications.commands`; + } + return ''; +} + +export function getDashboardPortValue(): number { + return getDashboardPort(); +} + +export function getDashboardUrlValue(): string { + return getDashboardUrl(); +} \ No newline at end of file diff --git a/apps/dashboard/src/api/guilds.ts b/apps/dashboard/src/api/guilds.ts new file mode 100644 index 000000000..62cbb9c20 --- /dev/null +++ b/apps/dashboard/src/api/guilds.ts @@ -0,0 +1,90 @@ +import http from 'node:http'; +import { BotDatabase } from '@master-bot/db'; +import type { DashboardContext } from '../context.js'; + +export async function handleDashboardGuilds( + req: http.IncomingMessage, + res: http.ServerResponse, + ctx: DashboardContext +): Promise { + const db = BotDatabase.getInstance(); + + if (req.method === 'GET') { + const botState = ctx.getBotState(); + const guildSettings: Record> = {}; + for (const g of botState.guilds) { + const guild = db.getGuild(g.id); + guildSettings[g.id] = guild || {}; + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + liveGuilds: botState.guilds, + guildSettings, + guildCount: botState.guilds.length + }) + ); + return; + } + + if (req.method === 'POST') { + let body = ''; + req.on('data', chunk => (body += chunk)); + req.on('end', () => { + try { + const payload = JSON.parse(body || '{}'); + const guildId = payload.guildId as string; + if (!guildId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'guildId is required' })); + return; + } + + const existing = db.getGuild(guildId); + if (!existing) { + const live = ctx.getBotState().guilds.find(g => g.id === guildId); + db.upsertGuild(guildId, live?.ownerId || '', live?.name || guildId); + } + + if (payload.welcomeMessage !== undefined) { + db.setWelcomeMessage(guildId, String(payload.welcomeMessage)); + } + if (payload.welcomeChannel !== undefined) { + db.setWelcomeChannel(guildId, String(payload.welcomeChannel)); + } + if (payload.welcomeEnabled !== undefined) { + db.toggleWelcome(guildId, Boolean(payload.welcomeEnabled)); + } + if (payload.ticketChannel !== undefined) { + db.setTicketChannel(guildId, String(payload.ticketChannel)); + } + if (payload.ticketTranscriptChannel !== undefined) { + db.setTicketTranscriptChannel(guildId, String(payload.ticketTranscriptChannel)); + } + if (payload.ticketRole !== undefined) { + db.setTicketRole(guildId, String(payload.ticketRole)); + } + if (payload.ticketEnabled !== undefined) { + db.toggleTicket(guildId, Boolean(payload.ticketEnabled)); + } + if (payload.logChannel !== undefined) { + db.setGuildLogChannel(guildId, String(payload.logChannel)); + } + if (payload.volume !== undefined) { + db.updateGuildVolume(guildId, Number(payload.volume)); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, guildId, message: 'Settings saved' })); + } catch (err: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message || 'Invalid payload' })); + } + }); + return; + } + + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); +} \ No newline at end of file diff --git a/apps/dashboard/src/api/stats.ts b/apps/dashboard/src/api/stats.ts new file mode 100644 index 000000000..fdfd1ae02 --- /dev/null +++ b/apps/dashboard/src/api/stats.ts @@ -0,0 +1,47 @@ +import http from 'node:http'; +import { BotDatabase } from '@master-bot/db'; +import type { DashboardContext } from '../context.js'; +import { getNextAuthConfig } from '../auth/config.js'; +import { + getBotToken, + getCallbackUrl, + getClientId, + getInviteUrl +} from './env.js'; + +export function handleDashboardStats( + req: http.IncomingMessage, + res: http.ServerResponse, + ctx: DashboardContext +): void { + const db = BotDatabase.getInstance(); + const stats = db.getStats(); + const botState = ctx.getBotState(); + + const data = { + bot: { + status: getBotToken() ? (botState.isReady ? 'online' : 'configured') : 'unconfigured', + isReady: botState.isReady, + gatewayLatencyMs: ctx.getGatewayLatency(), + clientId: getClientId() || null, + guildCount: botState.guilds.length, + callbackUrl: getCallbackUrl(), + inviteUrl: getInviteUrl(), + version: '1.0.0', + uptimeSeconds: Math.floor(process.uptime()), + connectedGuilds: botState.guilds.map(g => ({ id: g.id, name: g.name, icon: g.icon })) + }, + database: { + ...stats, + directConnection: true, + latencyMs: 0 // In-process SQLite has 0 network latency + }, + auth: { + enabled: Boolean(getNextAuthConfig().clientId), + url: getNextAuthConfig().url + } + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(data)); +} \ No newline at end of file diff --git a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts b/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index b3d4e3176..000000000 --- a/apps/dashboard/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { GET, POST } from '@master-bot/auth'; - -// @note If you wanna enable edge runtime, either -// - https://auth-docs-git-feat-nextjs-auth-authjs.vercel.app/guides/upgrade-to-v5#edge-compatibility -// - swap prisma for kysely / drizzle -// export const runtime = "edge"; diff --git a/apps/dashboard/src/app/api/trpc/[trpc]/route.ts b/apps/dashboard/src/app/api/trpc/[trpc]/route.ts deleted file mode 100644 index e2997e5d8..000000000 --- a/apps/dashboard/src/app/api/trpc/[trpc]/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; -import { appRouter, createTRPCContext } from '@master-bot/api'; - -const handler = (req: Request) => - fetchRequestHandler({ - req, - router: appRouter, - endpoint: '/api/trpc', - createContext: createTRPCContext - }); - -export { handler as GET, handler as POST }; diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/[command_id]/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/commands/[command_id]/page.tsx deleted file mode 100644 index 81b07273d..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/commands/[command_id]/page.tsx +++ /dev/null @@ -1,412 +0,0 @@ -'use client'; -import { - type APIRole, - type APIApplicationCommandPermission, - ApplicationCommandPermissionType -} from 'discord-api-types/v10'; -import { useState } from 'react'; -import { api } from '~/utils/api'; -import { useToast } from '~/components/ui/use-toast'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuTrigger -} from '~/components/ui/dropdown'; - -interface Role { - name: string; - id: string; - color: number; -} - -export default function CommandPage({ - params -}: { - params: { - server_id: string; - command_id: string; - }; -}) { - const { data, isLoading } = api.command.getCommandAndGuildChannels.useQuery( - { - guildId: params.server_id, - commandId: params.command_id - }, - { - refetchOnReconnect: false, - retryOnMount: false, - refetchOnWindowFocus: false - } - ); - - if (isLoading) return
Loading...
; - - if (!data?.command) return
Command not found
; - - return ( - <> -

Edit {data.command.name}

- - - ); -} - -const PermissionsEdit = ({ - roles, - allRoles, - guildId, - commandId -}: { - roles: { - allowedRoles: Role[]; - deniedRoles: Role[]; - }; - allRoles: APIRole[]; - guildId: string; - commandId: string; -}) => { - const { toast } = useToast(); - - const allowedIds = roles.allowedRoles.map(r => r.id); - const deniedIds = roles.deniedRoles.map(r => r.id); - - const [allowedRoles, setAllowedRoles] = useState(roles.allowedRoles); - const [deniedRoles, setDeniedRoles] = useState(roles.deniedRoles); - - const [disableSave, setDisableSave] = useState(false); - - const [selectedRadio, setSelectedRadio] = useState( - allowedIds.length ? 'deny' : 'allow' - ); - const isRadioSelected = (value: string) => selectedRadio === value; - - const handleRadioClick = (e: React.ChangeEvent): void => - setSelectedRadio(e.currentTarget.value); - - const { mutate } = api.command.editCommandPermissions.useMutation(); - const utils = api.useContext(); - - function handleRoleChange({ id, type }: { id: string; type: string }) { - if (type === 'allow') { - const newAllowedRoles = allowedRoles.filter(role => role.id !== id); - setAllowedRoles(newAllowedRoles); - } else if (type === 'deny') { - const newDeniedRoles = deniedRoles.filter(role => role.id !== id); - setDeniedRoles(newDeniedRoles); - } - } - - function handleSave() { - setDisableSave(true); - const allowedPerms = allowedRoles.map(role => ({ - id: role.id, - type: 1, - permission: true - })); - - const deniedPerms = deniedRoles.map(role => ({ - id: role.id, - type: 1, - permission: false - })); - - mutate( - { - guildId, - commandId, - permissions: selectedRadio === 'allow' ? deniedPerms : allowedPerms, - type: selectedRadio - }, - { - onSuccess: async () => { - await utils.command.getCommandAndGuildChannels.invalidate(); - setDisableSave(false); - toast({ - title: 'Permissions updated' - }); - }, - onError: () => { - setDisableSave(false); - toast({ - title: 'An error occurred while updating permissions.' - }); - }, - onSettled: () => { - setDisableSave(false); - } - } - ); - } - - return ( -
-
-

Permissions

- -
-
-

Role permissions

-
-
- -

Allow for everyone except

-
- {selectedRadio === 'deny' ? null : ( -
- {deniedRoles.map(role => { - if (role.name === '@everyone') return null; - return ( -
-
- {role.name == '@everyone' ? '@everyone' : `@${role.name}`} -
- - handleRoleChange({ id: role.id, type: 'deny' }) - } - > - - -
- ); - })} - - - - - - - {allRoles - .filter(role => !deniedIds.includes(role.id)) - .map(role => { - if (role.name === '@everyone') return; - - return ( - { - setDeniedRoles(state => [ - ...state, - { - id: role.id, - name: role.name, - color: role.color - } - ]); - - if (allowedIds.includes(role.id)) { - setAllowedRoles(state => - state.filter(r => r.id !== role.id) - ); - } - }} - > - {role.name} - - ); - })} - - - -
- )} -
-
-
- -

Deny for everyone except

-
- {selectedRadio === 'deny' ? ( -
- {allowedRoles.map(role => { - if (role.name == '@everyone') return null; - return ( -
- {role.name == '@everyone' ? '@everyone' : `@${role.name}`} - - handleRoleChange({ id: role.id, type: 'allow' }) - } - > - - -
- ); - })} - - - - - - - {allRoles - .filter(role => !allowedIds.includes(role.id)) - .map(role => { - if (role.name === '@everyone') return; - - return ( - { - setAllowedRoles(state => [ - ...state, - { - id: role.id, - name: role.name, - color: role.color - } - ]); - - if (deniedIds.includes(role.id)) { - setDeniedRoles(state => - state.filter(r => r.id !== role.id) - ); - } - }} - > - {role.name} - - ); - })} - - - -
- ) : null} -
-
-
- ); -}; - -function sortRolePermissions({ - roles, - permissions -}: { - roles: APIRole[]; - permissions: any; -}) { - if (permissions.code) { - return { - allowedRoles: [], - deniedRoles: [] - }; - } - - const allowedRoles: Role[] = permissions.permissions - .filter( - (permission: APIApplicationCommandPermission) => - permission.type === ApplicationCommandPermissionType.Role && - permission.permission - ) - .map((permission: APIApplicationCommandPermission) => { - const role = roles.find(roles => roles.id === permission.id); - - return { - name: role?.name, - id: role?.id, - color: role?.color - }; - }); - - const deniedRoles: Role[] = permissions.permissions - .filter( - (permission: APIApplicationCommandPermission) => - permission.type === ApplicationCommandPermissionType.Role && - !permission.permission - ) - .map((permission: APIApplicationCommandPermission) => { - const role = roles.find(roles => roles.id === permission.id); - - return { - name: role?.name, - id: role?.id, - color: role?.color - }; - }); - - return { - allowedRoles, - deniedRoles - }; -} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/actions.ts b/apps/dashboard/src/app/dashboard/[server_id]/commands/actions.ts deleted file mode 100644 index 4d21ba6bc..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/commands/actions.ts +++ /dev/null @@ -1,48 +0,0 @@ -'use server'; -import { prisma } from '@master-bot/db'; -import { revalidatePath } from 'next/cache'; - -export async function toggleCommand( - guildId: string, - commandId: string, - newStatus: boolean -) { - const guild = await prisma.guild.findUnique({ - where: { - id: guildId - }, - select: { - disabledCommands: true - } - }); - - if (!guild) { - throw new Error('Guild not found'); - } - - if (newStatus) { - await prisma.guild.update({ - where: { - id: guildId - }, - data: { - disabledCommands: { - set: guild.disabledCommands.filter(id => id !== commandId) - } - } - }); - } else { - await prisma.guild.update({ - where: { - id: guildId - }, - data: { - disabledCommands: { - push: commandId - } - } - }); - } - - revalidatePath(`/dashboard/${guildId}/commands`); -} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/loading.tsx b/apps/dashboard/src/app/dashboard/[server_id]/commands/loading.tsx deleted file mode 100644 index fa42e2371..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/commands/loading.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export default function Loading() { - // You can add any UI inside Loading, including a Skeleton. - return
Loading...
; -} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/commands/page.tsx deleted file mode 100644 index cc851fd97..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/commands/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { env } from '~/env.mjs'; -import { prisma } from '@master-bot/db'; -import type { APIApplicationCommand } from 'discord-api-types/v10'; -import CommandToggleSwitch from './toggle-command'; -import Link from 'next/link'; - -async function getApplicationCommands() { - // get all commands - const response = await fetch( - `https://discordapp.com/api/applications/${env.DISCORD_CLIENT_ID}/commands`, - { - headers: { - Authorization: `Bot ${env.DISCORD_TOKEN}` - } - } - ); - - return (await response.json()) as APIApplicationCommand[]; -} - -export default async function CommandsPage({ - params -}: { - params: { server_id: string }; -}) { - // get disabled commands - const guild = await prisma.guild.findUnique({ - where: { id: params.server_id }, - select: { disabledCommands: true } - }); - - const commands = await getApplicationCommands(); - - return ( -
-

- Enable / Disable Commands Panel -

- {commands ? ( -
- {commands.map(command => { - const isCommandEnabled = !guild?.disabledCommands.includes( - command.id - ); - return ( -
-
- -

{command.name}

- -

{command.description}

-
-
- -
-
- ); - })} -
- ) : ( -
Error loading commands
- )} -
- ); -} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/toggle-command.tsx b/apps/dashboard/src/app/dashboard/[server_id]/commands/toggle-command.tsx deleted file mode 100644 index ff5b8bd28..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/commands/toggle-command.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; - -import { Switch } from '~/components/ui/switch'; -import { startTransition } from 'react'; -import { toggleCommand } from './actions'; -import { useToast } from '~/components/ui/use-toast'; -import { ToastAction } from '~/components/ui/toast'; - -export default function CommandToggleSwitch({ - commandEnabled, - serverId, - commandId -}: { - commandEnabled: boolean; - serverId: string; - commandId: string; -}) { - const { toast } = useToast(); - - return ( - - startTransition(() => - // @ts-ignore - toggleCommand(serverId, commandId, !commandEnabled).then(() => { - toast({ - title: `Command ${commandEnabled ? 'disabled' : 'enabled'}`, - action: Okay - }); - }) - ) - } - /> - ); -} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/layout.tsx b/apps/dashboard/src/app/dashboard/[server_id]/layout.tsx deleted file mode 100644 index e1af2652f..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/layout.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { auth } from '@master-bot/auth'; -import { redirect } from 'next/navigation'; -import { prisma } from '@master-bot/db'; -import Sidebar from './sidebar'; -import HeaderButtons from '~/components/header-buttons'; - -export default async function Layout({ - params, - children -}: { - params: { server_id: string }; - children: React.ReactNode; -}) { - const session = await auth(); - - if (!session?.user) { - redirect('/'); - } - - const guild = await prisma.guild.findUnique({ - where: { - id: params.server_id, - ownerId: session.user.discordId - } - }); - - if (!guild) { - redirect('/'); - } - - return ( -
-
- -
-
-
- -
-
- {children} -
-
-
- ); -} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/page.tsx deleted file mode 100644 index 3cbf57e73..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function ServerIndexPage() { - return ( -
-

Guild index page

-
- ); -} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx b/apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx deleted file mode 100644 index 309ca904f..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import Link from 'next/link'; -import { MessageCircle, ChevronRightSquare } from 'lucide-react'; -import Logo from '~/components/logo'; - -const links = [ - { - href: 'commands', - label: 'Commands', - icon: ChevronRightSquare - }, - { - href: 'welcome-message', - label: 'Welcome Message', - icon: MessageCircle - } -]; - -export default function Sidebar({ server_id }: { server_id: string }) { - return ( - - ); -} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/actions.ts b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/actions.ts deleted file mode 100644 index 8622c56a1..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/actions.ts +++ /dev/null @@ -1,32 +0,0 @@ -'use server'; -import { prisma } from '@master-bot/db'; -import { revalidatePath } from 'next/cache'; - -export async function toggleWelcomeMessage(status: boolean, server_id: string) { - await prisma.guild.update({ - where: { - id: server_id - }, - data: { - welcomeMessageEnabled: status - } - }); - - revalidatePath(`/dashboard/${server_id}/welcome-message`); -} - -export async function setWelcomeMessage(data: FormData) { - const guildId = data.get('guildId') as string; - const message = data.get('message') as string; - - await prisma.guild.update({ - where: { - id: guildId - }, - data: { - welcomeMessage: message - } - }); - - revalidatePath(`/dashboard/${guildId}/welcome-message`); -} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/loading.tsx b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/loading.tsx deleted file mode 100644 index fa42e2371..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/loading.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export default function Loading() { - // You can add any UI inside Loading, including a Skeleton. - return
Loading...
; -} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/page.tsx deleted file mode 100644 index 24562a75b..000000000 --- a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/page.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { prisma } from '@master-bot/db'; -import WelcomeMessageToggle from './switch'; -import { setWelcomeMessage } from './actions'; -import { Button } from '~/components/ui/button'; -import WelcomeMessageChannelSet from './set-channel'; - -function getGuildById(id: string) { - return prisma.guild.findUnique({ - where: { - id - } - }); -} - -export default async function WelcomeMessagePage({ - params -}: { - params: { server_id: string }; -}) { - const guild = await getGuildById(params.server_id); - - if (!guild) { - return
Error loading guild
; - } - - return ( - <> -

Welcome Message Settings

-
-

Welcome new users with a custom message

-
- {guild.welcomeMessageEnabled ? ( -

Enabled

- ) : ( -

Disabled

- )} - -
- {guild.welcomeMessageEnabled && ( -
-
- - +
+ + + +
+ + + + + +`; +} \ No newline at end of file diff --git a/apps/dashboard/src/utils/api.ts b/apps/dashboard/src/utils/api.ts deleted file mode 100644 index c536c1f88..000000000 --- a/apps/dashboard/src/utils/api.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { AppRouter } from '@master-bot/api'; -import { createTRPCReact } from '@trpc/react-query'; - -export const api = createTRPCReact(); - -export { type RouterInputs, type RouterOutputs } from '@master-bot/api'; diff --git a/apps/dashboard/tailwind.config.js b/apps/dashboard/tailwind.config.js deleted file mode 100644 index 4e8ecc0d9..000000000 --- a/apps/dashboard/tailwind.config.js +++ /dev/null @@ -1,71 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - darkMode: ['class'], - content: ['src/app/**/*.{ts,tsx}', 'src/components/**/*.{ts,tsx}'], - theme: { - container: { - center: true, - padding: '2rem', - screens: { - '2xl': '1400px' - } - }, - extend: { - colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive) / )', - foreground: 'hsl(var(--destructive-foreground) / )' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - } - }, - borderRadius: { - lg: `var(--radius)`, - md: `calc(var(--radius) - 2px)`, - sm: 'calc(var(--radius) - 4px)' - }, - keyframes: { - 'accordion-down': { - from: { height: 0 }, - to: { height: 'var(--radix-accordion-content-height)' } - }, - 'accordion-up': { - from: { height: 'var(--radix-accordion-content-height)' }, - to: { height: 0 } - } - }, - animation: { - 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out' - } - } - }, - plugins: [require('tailwindcss-animate')] -}; diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json index 707f7f295..6b23777d3 100644 --- a/apps/dashboard/tsconfig.json +++ b/apps/dashboard/tsconfig.json @@ -1,13 +1,18 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "baseUrl": ".", - "paths": { - "~/*": ["./src/*"] - }, - "plugins": [{ "name": "next" }], - "strict": true + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "src", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true }, - "include": ["next-env.d.ts", "src", "*.ts", "*.mjs", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/docker-compose.yml b/docker-compose.yml index ec8317b4d..efa7ba17c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,85 +1,37 @@ -version: '3' +version: '3.8' services: master-bot: + container_name: master-bot platform: 'linux/amd64' env_file: - - docker.env + - .env + environment: + # Bot, dashboard and OAuth2 callback server share ONE port + PORT: 3000 + DISCORD_DB_PATH: /app/data/bot.sqlite + LAVA_HOST: lavalink + LAVA_PORT: 2333 restart: always build: . ports: - - '3000:3000' # Dashboard - # - "5555:5555" # Prisma Studio Port - uncomment to open - command: > - sh -c "pnpm run db:push && pnpm run -r start" + - '3000:3000' # Unified Master-Bot (bot + embedded dashboard + OAuth2) + command: pnpm start depends_on: lavalink: condition: service_healthy - postgres: - condition: service_healthy - redis: - condition: service_healthy - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # Password is required and must match '.env' file - POSTGRES_DB_NAME: ${POSTGRES_DB_NAME} # Must match '.env' file - POSTGRES_PORT: ${POSTGRES_PORT} - POSTGRES_HOST: ${POSTGRES_HOST} # Must match '.env' file - REDIS_HOST: ${REDIS_HOST} # Must match service name - REDIS_PORT: ${REDIS_PORT} - REDIS_DB: ${REDIS_DB} - links: - - lavalink - - redis - - postgres volumes: - - ./logs:/Master-Bot/apps/bot/logs + - ./data:/app/data + - ./logs:/app/logs lavalink: + container_name: master-bot-lavalink restart: always - image: fredboat/lavalink:3-alpine - healthcheck: - test: 'echo lavalink' - interval: 10s - timeout: 10s - retries: 3 - volumes: - - ./application.yml:/opt/Lavalink/application.yml - postgres: - env_file: - - docker.env - image: postgres:15-alpine - restart: always + image: ghcr.io/lavalink-devs/lavalink:4-alpine + ports: + - '2333:2333' healthcheck: - test: - ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB_NAME}'] + test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:2333/version'] interval: 10s timeout: 5s retries: 5 - environment: - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB_NAME=${POSTGRES_DB_NAME} - - POSTGRES_PORT=${POSTGRES_PORT} - volumes: - - postgres:/var/lib/postgresql/data - redis: - env_file: - - docker.env - image: redis:7-alpine - restart: always - environment: - - ALLOW_EMPTY_PASSWORD=yes - - REDIS_PORT=${REDIS_PORT} - - REDIS_DB=${REDIS_DB} - command: redis-server --save 20 1 --loglevel warning - healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 10s - timeout: 10s - retries: 3 volumes: - - redis:/data -volumes: - postgres: - driver: local - redis: - driver: local + - ./application.yml:/opt/Lavalink/application.yml \ No newline at end of file diff --git a/docker.env b/docker.env index 947ddb8bf..b7b654508 100644 --- a/docker.env +++ b/docker.env @@ -1,26 +1,19 @@ - # Editing this file is not required and used for Docker-Compose Only - # these will overwrite the needed .env variables to create and link ALL the containers correctly - # Fill out your .env as normal then to dockerize - # run "docker compose --env-file docker.env up -d --build" in root folder - - # Prisma Override - DATABASE_URL="postgresql://postgresUsername:postgresPassword@postgres:5432/master-bot?schema=public&connect_timeout=300" +# Editing this file is not required and used for Docker-Compose Only + # These values override the .env to correctly create and link all containers. + # Fill out your .env as normal, then to dockerize run + # "docker compose --env-file docker.env up -d --build" in the root folder - # LavaLink Docker Container + # Unified Runtime Port (bot, embedded dashboard and OAuth2 callback server + # all share this single port; exposed as 3000:3000 by docker-compose.yml) + PORT=3000 + + # SQLite database is stored on the host via the ./data:/app/data volume; + # DISCORD_DB_PATH keeps the bot writing to that mount (auto-created on first start) + DISCORD_DB_PATH=/app/data/bot.sqlite + + # Lavalink Docker Container + LAVA_ENABLED=true LAVA_HOST="lavalink" LAVA_PASS="youshallnotpass" LAVA_PORT=2333 - LAVA_SECURE=false - - # Postgres Docker Container - POSTGRES_HOST="postgres" - POSTGRES_USER="postgresUsername" - POSTGRES_PORT=5432 - POSTGRES_PASSWORD="postgresPassword" - POSTGRES_DB_NAME="master-bot" - - # Redis Docker Container - REDIS_HOST="redis" - REDIS_PORT=6379 - REDIS_DB=0 - REDIS_PASSWORD="redisPassword" \ No newline at end of file + LAVA_SECURE=false \ No newline at end of file diff --git a/package.json b/package.json index a19faf8e9..cf48ca5d7 100644 --- a/package.json +++ b/package.json @@ -2,31 +2,38 @@ "name": "master-bot-turbo", "private": true, "engines": { - "node": ">=v20.0.0" + "node": ">=22.0.0" }, "packageManager": "pnpm@8.6.7", "scripts": { "build": "turbo build", "clean": "git clean -xdf node_modules", "clean:workspaces": "turbo clean", - "db:generate": "turbo db:generate", - "db:push": "turbo db:push db:generate", - "db:studio": "pnpm -F db dev", - "dev": "turbo dev", + "dev": "node scripts/dev.mjs", + "start": "node scripts/start.mjs", + "dev:turbo": "turbo dev", + "start:turbo": "turbo start", "dev-parallel": "turbo dev --parallel", "format": "prettier --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\" --ignore-path .gitignore", "lint": "turbo lint && manypkg check", "lint:fix": "turbo lint:fix && manypkg fix", "type-check": "turbo type-check", - "postinstall": "pnpm db:push", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:types": "tsc -p tsconfig.test.json", "docker-compose": "docker compose --env-file docker.env up -d --build" }, - "dependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.1.1", - "@manypkg/cli": "^0.21.0", - "prettier": "^3.1.0", - "prettier-plugin-tailwindcss": "^0.5.7", - "turbo": "^1.10.16", - "typescript": "^5.3.2" + "devDependencies": { + "@ianvs/prettier-plugin-sort-imports": "^4.7.1", + "@manypkg/cli": "^0.25.1", + "@types/node": "^22.5.4", + "@vitest/coverage-v8": "^4.1.0", + "prettier": "^3.9.6", + "prettier-plugin-tailwindcss": "^0.8.1", + "tsx": "^4.19.1", + "turbo": "^1.13.4", + "typescript": "^5.5.4", + "vitest": "^4.1.0" } } diff --git a/packages/api/index.ts b/packages/api/index.ts deleted file mode 100644 index 8f701d238..000000000 --- a/packages/api/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; - -import type { AppRouter } from './src/root'; - -export { appRouter, type AppRouter } from './src/root'; -export { createTRPCContext } from './src/trpc'; - -/** - * Inference helpers for input types - * @example type HelloInput = RouterInputs['example']['hello'] - **/ -export type RouterInputs = inferRouterInputs; - -/** - * Inference helpers for output types - * @example type HelloOutput = RouterOutputs['example']['hello'] - **/ -export type RouterOutputs = inferRouterOutputs; diff --git a/packages/api/package.json b/packages/api/package.json deleted file mode 100644 index 47b496cbc..000000000 --- a/packages/api/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@master-bot/api", - "version": "0.1.0", - "main": "./index.ts", - "types": "./index.ts", - "license": "ISC", - "scripts": { - "clean": "rm -rf .turbo node_modules", - "lint": "eslint .", - "lint:fix": "pnpm lint --fix", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@master-bot/auth": "^0.1.0", - "@master-bot/db": "^0.1.0", - "@t3-oss/env-core": "^0.7.1", - "@trpc/client": "next", - "@trpc/server": "next", - "axios": "^1.6.2", - "discord-api-types": "^0.37.64", - "superjson": "1.13.3", - "zod": "^3.22.4" - }, - "devDependencies": { - "@master-bot/eslint-config": "^0.2.0", - "dotenv": "^16.3.1", - "eslint": "^8.54.0", - "typescript": "^5.3.2" - }, - "eslintConfig": { - "root": true, - "extends": [ - "@master-bot/eslint-config/base" - ] - } -} diff --git a/packages/api/src/env.mjs b/packages/api/src/env.mjs deleted file mode 100644 index 7e51f8f7d..000000000 --- a/packages/api/src/env.mjs +++ /dev/null @@ -1,34 +0,0 @@ -import { createEnv } from '@t3-oss/env-core'; -import { z } from 'zod'; - -export const env = createEnv({ - clientPrefix: '', - /** - * Specify your server-side environment variables schema here. This way you can ensure the app isn't - * built with invalid env vars. - */ - server: { - DATABASE_URL: z.string(), - DISCORD_TOKEN: z.string(), - DISCORD_CLIENT_ID: z.string(), - DISCORD_CLIENT_SECRET: z.string() - }, - /** - * Specify your client-side environment variables schema here. - * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. - */ - client: { - // NEXT_PUBLIC_CLIENTVAR: z.string(), - }, - /** - * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. - */ - runtimeEnv: { - DATABASE_URL: process.env.DATABASE_URL, - DISCORD_TOKEN: process.env.DISCORD_TOKEN, - DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, - DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET - // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, - }, - skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION -}); diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts deleted file mode 100644 index 43d2f98ef..000000000 --- a/packages/api/src/root.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { channelRouter } from './routers/channel'; -import { commandRouter } from './routers/command'; -import { guildRouter } from './routers/guild'; -import { hubRouter } from './routers/hub'; -import { playlistRouter } from './routers/playlist'; -import { reminderRouter } from './routers/reminder'; -import { songRouter } from './routers/song'; -import { twitchRouter } from './routers/twitch'; -import { userRouter } from './routers/user'; -import { welcomeRouter } from './routers/welcome'; -import { createTRPCRouter } from './trpc'; - -export const appRouter = createTRPCRouter({ - user: userRouter, - guild: guildRouter, - playlist: playlistRouter, - song: songRouter, - twitch: twitchRouter, - channel: channelRouter, - welcome: welcomeRouter, - command: commandRouter, - hub: hubRouter, - reminder: reminderRouter -}); - -// export type definition of API -export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/channel.ts b/packages/api/src/routers/channel.ts deleted file mode 100644 index 9003640b3..000000000 --- a/packages/api/src/routers/channel.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { getFetch } from '@trpc/client'; -import type { - APIGuildChannel, - APIGuildTextChannel -} from 'discord-api-types/v10'; -import { z } from 'zod'; - -import { env } from '../env.mjs'; -import { createTRPCRouter, publicProcedure } from '../trpc'; - -const fetch = getFetch(); - -export const channelRouter = createTRPCRouter({ - getAll: publicProcedure - .input( - z.object({ - guildId: z.string() - }) - ) - .query(async ({ input }) => { - const { guildId } = input; - - const token = env.DISCORD_TOKEN; - - // call the discord api with the token and the guildId and get all the guild's text channels - const response = await fetch( - `https://discordapp.com/api/guilds/${guildId}/channels`, - { - headers: { - Authorization: `Bot ${token}` - } - } - ); - const responseChannels = - (await response.json()) as APIGuildChannel[]; - - const channels: APIGuildTextChannel<0>[] = responseChannels.filter( - channel => channel.type === 0 - ); - return { channels }; - }) -}); diff --git a/packages/api/src/routers/command.ts b/packages/api/src/routers/command.ts deleted file mode 100644 index 63ae5eb1f..000000000 --- a/packages/api/src/routers/command.ts +++ /dev/null @@ -1,361 +0,0 @@ -import { getFetch } from '@trpc/client'; -import { TRPCError } from '@trpc/server'; -import type { - APIApplicationCommandPermission, - APIGuildChannel, - APIRole, - ChannelType -} from 'discord-api-types/v10'; -import { z } from 'zod'; - -import { env } from '../env.mjs'; -import { createTRPCRouter, publicProcedure } from '../trpc'; -import { discordApi } from '../utils/axiosWithRefresh'; - -const fetch = getFetch(); - -export interface CommandType { - code: number; - id: string; - applicationId: string; - version: string; - default_permission: string; - default_member_permissions: null | string[]; - type: number; - name: string; - description: string; - dm_permission: boolean; - options: any[]; -} - -export interface CommandPermissionsResponseOkay { - id: string; - application_id: string; - guild_id: string; - permissions: APIApplicationCommandPermission[]; -} - -export interface CommandPermissionsResponseNotOkay { - message: string; - code: number; -} - -export const commandRouter = createTRPCRouter({ - getDisabledCommands: publicProcedure - .input( - z.object({ - guildId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { guildId } = input; - - const guild = await ctx.prisma.guild.findUnique({ - where: { - id: guildId - }, - select: { - disabledCommands: true - } - }); - - if (!guild) { - throw new TRPCError({ - message: 'Guild not found', - code: 'NOT_FOUND' - }); - } - - return { disabledCommands: guild.disabledCommands }; - }), - getCommands: publicProcedure - .input( - z.object({ - guildId: z.string() - }) - ) - .query(async () => { - try { - const token = env.DISCORD_TOKEN; - const response = await fetch( - `https://discordapp.com/api/applications/${env.DISCORD_CLIENT_ID}/commands`, - { - headers: { - Authorization: `Bot ${token}` - } - } - ); - const commands = (await response.json()) as CommandType[]; - - return { commands }; - } catch (e) { - console.error(e); - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Something went wrong when trying to fetch guilds' - }); - } - }), - getCommandAndGuildChannels: publicProcedure - .input( - z.object({ - guildId: z.string(), - commandId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - if (!ctx.session) { - throw new TRPCError({ - message: 'Not Authenticated', - code: 'UNAUTHORIZED' - }); - } - - const token = env.DISCORD_TOKEN; - const clientID = env.DISCORD_CLIENT_ID; - const { guildId, commandId } = input; - - const account = await ctx.prisma.account.findFirst({ - where: { - userId: ctx.session?.user?.id - }, - select: { - access_token: true, - providerAccountId: true, - user: { - select: { - discordId: true - } - } - } - }); - - try { - const [ - guildChannelsResponse, - guildRolesResponse, - commandResponse, - permissionsResponse - ] = await Promise.all([ - fetch(`https://discord.com/api/guilds/${guildId}/channels`, { - headers: { - Authorization: `Bot ${token}` - } - }).then((res: any) => res.json()) as Promise, - fetch(`https://discord.com/api/guilds/${guildId}/roles`, { - headers: { - Authorization: `Bot ${token}` - } - }).then((res: any) => res.json()) as Promise, - fetch( - `https://discord.com/api/applications/${clientID}/commands/${commandId}`, - { - headers: { - Authorization: `Bot ${token}` - } - } - ).then((res: any) => res.json()) as Promise, - discordApi - .get( - `https://discord.com/api/v10/applications/${clientID}/guilds/${guildId}/commands/${commandId}/permissions`, - { - headers: { - Authorization: `Bearer ${account?.access_token}` - } - } - ) - .then((res: any) => res.data) - ]); - - const channels = - guildChannelsResponse as APIGuildChannel[]; - const roles = guildRolesResponse as APIRole[]; - const command = commandResponse as CommandType; - const permissions = permissionsResponse; - - return { channels, roles, command, permissions }; - } catch { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Something went wrong when trying to fetch guilds' - }); - } - }), - getCommandPermissions: publicProcedure - .input( - z.object({ - guildId: z.string(), - commandId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const clientID = env.DISCORD_CLIENT_ID; - const { guildId, commandId } = input; - - if (!ctx.session) { - throw new TRPCError({ - message: 'Not Authenticated', - code: 'UNAUTHORIZED' - }); - } - - const account = await ctx.prisma.account.findFirst({ - where: { - userId: ctx.session?.user?.id - }, - select: { - access_token: true, - providerAccountId: true, - user: { - select: { - discordId: true - } - } - } - }); - try { - const response = await fetch( - `https://discord.com/api/applications/${clientID}/guilds/${guildId}/commands/${commandId}/permissions`, - { - headers: { - Authorization: `Bearer ${account?.access_token}` - } - } - ); - const command = await response.json(); - if (!command) throw new Error(); - - return { command }; - } catch { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Something went wrong when trying to fetch guilds' - }); - } - }), - editCommandPermissions: publicProcedure - .input( - z.object({ - guildId: z.string(), - commandId: z.string(), - permissions: z.array( - z.object({ - id: z.string(), - type: z.number(), - permission: z.boolean() - }) - ), - type: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const clientID = env.DISCORD_CLIENT_ID; - const { guildId, commandId, permissions, type } = input; - if (!ctx.session) { - throw new TRPCError({ - message: 'Not Authenticated', - code: 'UNAUTHORIZED' - }); - } - - const account = await ctx.prisma.account.findFirst({ - where: { - userId: ctx.session?.user?.id - }, - select: { - access_token: true, - providerAccountId: true, - user: { - select: { - discordId: true - } - } - } - }); - - const everyone = { - id: guildId, - type: 1, - permission: type === 'allow' ? true : false - }; - - try { - const response = await fetch( - `https://discord.com/api/applications/${clientID}/guilds/${guildId}/commands/${commandId}/permissions`, - { - method: 'PUT', - headers: { - Authorization: `Bearer ${account?.access_token}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ permissions: [everyone, ...permissions] }) - } - ); - const command = await response.json(); - if (!command) throw new Error(); - - return { command }; - } catch { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Something went wrong when trying to fetch guilds' - }); - } - }), - - toggleCommand: publicProcedure - .input( - z.object({ - guildId: z.string(), - commandId: z.string(), - status: z.boolean() - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, commandId, status } = input; - - const guild = await ctx.prisma.guild.findUnique({ - where: { - id: guildId - }, - select: { - disabledCommands: true - } - }); - - if (!guild) { - throw new TRPCError({ - message: 'Guild not found', - code: 'NOT_FOUND' - }); - } - - let updatedGuild; - - if (status) { - updatedGuild = await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - disabledCommands: { - set: [...guild.disabledCommands, commandId] - } - } - }); - } else { - updatedGuild = await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - disabledCommands: { - set: guild?.disabledCommands.filter(cid => cid !== commandId) - } - } - }); - } - - return { updatedGuild }; - }) -}); diff --git a/packages/api/src/routers/guild.ts b/packages/api/src/routers/guild.ts deleted file mode 100644 index d96c9f372..000000000 --- a/packages/api/src/routers/guild.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { getFetch } from '@trpc/client'; -import { TRPCError } from '@trpc/server'; -import type { APIGuild, APIRole } from 'discord-api-types/v10'; -import { z } from 'zod'; -import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; -import { discordApi } from '../utils/axiosWithRefresh'; - -const fetch = getFetch(); - -function getUserGuilds( - access_token: string, - refresh_token: string, - user_id: string -) { - return discordApi.get('https://discord.com/api/v10/users/@me/guilds', { - headers: { - Authorization: `Bearer ${access_token}`, - // set user agent - 'User-Agent': - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', - 'X-User-Id': user_id, - 'X-Refresh-Token': refresh_token - } - }); -} - -export const guildRouter = createTRPCRouter({ - getGuild: publicProcedure - .input( - z.object({ - id: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { id } = input; - - const guild = await ctx.prisma.guild.findUnique({ - where: { - id - } - }); - - return { guild }; - }), - create: publicProcedure - .input( - z.object({ - id: z.string(), - ownerId: z.string(), - name: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, ownerId, name } = input; - - const guild = await ctx.prisma.guild.upsert({ - where: { - id: id - }, - update: {}, - create: { - id: id, - ownerId: ownerId, - volume: 100, - name: name - } - }); - - return { guild }; - }), - delete: publicProcedure - .input( - z.object({ - id: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - const guild = await ctx.prisma.guild.delete({ - where: { - id: id - } - }); - - return { guild }; - }), - updateVolume: publicProcedure - .input( - z.object({ - guildId: z.string(), - volume: z.number() - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, volume } = input; - - await ctx.prisma.guild.update({ - where: { id: guildId }, - data: { volume } - }); - }), - getRoles: publicProcedure - .input( - z.object({ - guildId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { guildId } = input; - const token = process.env.DISCORD_TOKEN; - - if (!ctx.session) { - throw new TRPCError({ - message: 'Not Authenticated', - code: 'UNAUTHORIZED' - }); - } - - const response = await fetch( - `https://discord.com/api/guilds/${guildId}/roles`, - { - headers: { - Authorization: `Bot ${token}` - } - } - ); - - const roles = (await response.json()) as APIRole[]; - - return { roles }; - }), - getAll: protectedProcedure.query(async ({ ctx }) => { - const account = await ctx.prisma.account.findFirst({ - where: { - userId: ctx.session?.user?.id - } - }); - - if (!account?.access_token || !account?.refresh_token) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Account not found' - }); - } - - try { - const dbGuilds = await ctx.prisma.guild.findMany({ - where: { - ownerId: account.providerAccountId - } - }); - - const response = await getUserGuilds( - account.access_token, - account.refresh_token, - account.userId - ); - - // get the guilds from response data - const apiGuilds = response.data as APIGuild[]; - - const apiGuildsOwns = apiGuilds.filter(guild => guild.owner); - - return { - apiGuilds: apiGuildsOwns, - dbGuilds, - apiGuildsIds: apiGuildsOwns.map(guild => guild.id), - dbGuildsIds: dbGuilds.map(guild => guild.id) - }; - } catch (error) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Something went wrong when trying to fetch guilds from DB' - }); - } - }) -}); diff --git a/packages/api/src/routers/hub.ts b/packages/api/src/routers/hub.ts deleted file mode 100644 index b14d3b02d..000000000 --- a/packages/api/src/routers/hub.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { getFetch } from '@trpc/client'; -import { TRPCError } from '@trpc/server'; -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -const fetch = getFetch(); - -export const hubRouter = createTRPCRouter({ - create: publicProcedure - .input( - z.object({ - guildId: z.string(), - name: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, name } = input; - const token = process.env.DISCORD_TOKEN; - - let parent; - try { - const response = await fetch( - `https://discordapp.com/api/guilds/${guildId}/channels`, - { - headers: { - Authorization: `Bot ${token}`, - 'Content-Type': 'application/json' - }, - method: 'POST', - body: JSON.stringify({ - name, - type: 4 - }) - } - ); - parent = (await response.json()) as any; - } catch (e) { - console.log(e); - throw new TRPCError({ - message: 'Could not create channel', - code: 'INTERNAL_SERVER_ERROR' - }); - } - - let hubChannel; - try { - const response = await fetch( - `https://discordapp.com/api/guilds/${guildId}/channels`, - { - headers: { - Authorization: `Bot ${token}`, - 'Content-Type': 'application/json' - }, - method: 'POST', - body: JSON.stringify({ - name: 'Join To Create', - type: 2, - parent_id: parent.id - }) - } - ); - hubChannel = (await response.json()) as any; - } catch { - throw new TRPCError({ - message: 'Could not create channel', - code: 'INTERNAL_SERVER_ERROR' - }); - } - - const updatedGuild = await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - hub: parent.id, - hubChannel: hubChannel.id - } - }); - - return { - guild: updatedGuild - }; - }), - delete: publicProcedure - .input( - z.object({ - guildId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId } = input; - - const token = process.env.DISCORD_TOKEN; - - const guild = await ctx.prisma.guild.findUnique({ - where: { - id: guildId - }, - select: { - hub: true, - hubChannel: true - } - }); - - if (!guild) { - throw new TRPCError({ - message: 'Guild not found', - code: 'NOT_FOUND' - }); - } - - try { - Promise.all([ - fetch(`https://discordapp.com/api/channels/${guild.hubChannel}`, { - headers: { - Authorization: `Bot ${token}` - }, - method: 'DELETE' - }), - fetch(`https://discordapp.com/api/channels/${guild.hub}`, { - headers: { - Authorization: `Bot ${token}` - }, - method: 'DELETE' - }) - ]).then(async () => { - await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - hub: null, - hubChannel: null - } - }); - }); - } catch (e) { - console.log(e); - throw new TRPCError({ - message: 'Could not delete channel', - code: 'INTERNAL_SERVER_ERROR' - }); - } - }), - getTempChannel: publicProcedure - .input( - z.object({ - guildId: z.string(), - ownerId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { guildId, ownerId } = input; - - const tempChannel = await ctx.prisma.tempChannel.findFirst({ - where: { - guildId, - ownerId - } - }); - - return { tempChannel }; - }), - createTempChannel: publicProcedure - .input( - z.object({ - guildId: z.string(), - ownerId: z.string(), - channelId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, ownerId, channelId } = input; - - const tempChannel = await ctx.prisma.tempChannel.create({ - data: { - guildId, - ownerId, - id: channelId - } - }); - - return { tempChannel }; - }), - deleteTempChannel: publicProcedure - .input( - z.object({ - channelId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { channelId } = input; - - const tempChannel = await ctx.prisma.tempChannel.delete({ - where: { - id: channelId - } - }); - - return { tempChannel }; - }) -}); diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts deleted file mode 100644 index 1a5e86e00..000000000 --- a/packages/api/src/routers/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createTRPCRouter } from '../trpc'; -import { channelRouter } from './channel'; -import { commandRouter } from './command'; -import { guildRouter } from './guild'; -import { hubRouter } from './hub'; -import { playlistRouter } from './playlist'; -import { reminderRouter } from './reminder'; -import { songRouter } from './song'; -import { twitchRouter } from './twitch'; -import { userRouter } from './user'; -import { welcomeRouter } from './welcome'; - -/** - * Create your application's root router - * If you want to use SSG, you need export this - * @link https://trpc.io/docs/ssg - * @link https://trpc.io/docs/router - */ - -export const appRouter = createTRPCRouter({ - user: userRouter, - guild: guildRouter, - playlist: playlistRouter, - song: songRouter, - twitch: twitchRouter, - channel: channelRouter, - welcome: welcomeRouter, - command: commandRouter, - hub: hubRouter, - reminder: reminderRouter -}); - -export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/playlist.ts b/packages/api/src/routers/playlist.ts deleted file mode 100644 index e48dde109..000000000 --- a/packages/api/src/routers/playlist.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const playlistRouter = createTRPCRouter({ - getPlaylist: publicProcedure - .input( - z.object({ - userId: z.string(), - name: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { userId, name } = input; - - const playlist = await ctx.prisma.playlist.findFirst({ - where: { - userId, - name - }, - include: { - songs: true - } - }); - - return { playlist }; - }), - getAll: publicProcedure - .input( - z.object({ - userId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { userId } = input; - - const playlists = await ctx.prisma.playlist.findMany({ - where: { - userId - }, - include: { - songs: true - }, - orderBy: { - id: 'asc' - } - }); - - return { playlists }; - }), - create: publicProcedure - .input( - z.object({ - userId: z.string(), - name: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, name } = input; - - const playlist = await ctx.prisma.playlist.create({ - data: { - name, - user: { - connect: { - id: userId - } - } - } - }); - - return { playlist }; - }), - delete: publicProcedure - .input( - z.object({ - userId: z.string(), - name: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, name } = input; - - const playlist = await ctx.prisma.playlist.deleteMany({ - where: { - userId, - name - } - }); - - return { playlist }; - }) -}); diff --git a/packages/api/src/routers/reminder.ts b/packages/api/src/routers/reminder.ts deleted file mode 100644 index a149edb4d..000000000 --- a/packages/api/src/routers/reminder.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const reminderRouter = createTRPCRouter({ - getAll: publicProcedure.query(async ({ ctx }) => { - const reminders = await ctx.prisma.reminder.findMany(); - - return { reminders }; - }), - getReminder: publicProcedure - .input( - z.object({ - userId: z.string(), - event: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, event } = input; - - const reminder = await ctx.prisma.reminder.findFirst({ - where: { - userId, - event - }, - include: { user: true } - }); - - return { reminder }; - }), - getByUserId: publicProcedure - .input( - z.object({ - userId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId } = input; - - const reminders = await ctx.prisma.reminder.findMany({ - where: { - userId - }, - select: { - event: true, - dateTime: true, - description: true - }, - orderBy: { - id: 'asc' - } - }); - - return { reminders }; - }), - create: publicProcedure - .input( - z.object({ - userId: z.string(), - event: z.string(), - description: z.nullable(z.string()), - dateTime: z.string(), - repeat: z.nullable(z.string()), - timeOffset: z.number() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, event, description, dateTime, repeat, timeOffset } = - input; - - const reminder = await ctx.prisma.reminder.create({ - data: { - event, - description, - dateTime, - repeat, - timeOffset, - user: { connect: { discordId: userId } } - } - }); - - return { reminder }; - }), - delete: publicProcedure - .input( - z.object({ - userId: z.string(), - event: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, event } = input; - - const reminder = await ctx.prisma.reminder.deleteMany({ - where: { - userId, - event - } - }); - - return { reminder }; - }) -}); diff --git a/packages/api/src/routers/song.ts b/packages/api/src/routers/song.ts deleted file mode 100644 index 964e05b71..000000000 --- a/packages/api/src/routers/song.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const songRouter = createTRPCRouter({ - createMany: publicProcedure - .input( - z.object({ - songs: z.array(z.any()) - }) - ) - .mutation(async ({ ctx, input }) => { - const { songs } = input; - - const songsCreated = await ctx.prisma.song.createMany({ - data: songs - }); - - return { songsCreated }; - }), - delete: publicProcedure - .input( - z.object({ - id: z.number() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - const song = await ctx.prisma.song.delete({ - where: { - id: id - } - }); - - return { song }; - }) -}); diff --git a/packages/api/src/routers/twitch.ts b/packages/api/src/routers/twitch.ts deleted file mode 100644 index 605e57179..000000000 --- a/packages/api/src/routers/twitch.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const twitchRouter = createTRPCRouter({ - getAll: publicProcedure.query(async ({ ctx }) => { - const notifications = await ctx.prisma.twitchNotify.findMany(); - - return { notifications }; - }), - findUserById: publicProcedure - .input( - z.object({ - id: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { id } = input; - - const notification = await ctx.prisma.twitchNotify.findFirst({ - where: { - twitchId: id - } - }); - - return { notification }; - }), - create: publicProcedure - .input( - z.object({ - userId: z.string(), - userImage: z.string(), - channelId: z.string(), - sendTo: z.array(z.string()) - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, userImage, channelId, sendTo } = input; - await ctx.prisma.twitchNotify.upsert({ - create: { - twitchId: userId, - channelIds: [channelId], - logo: userImage, - sent: false - }, - update: { channelIds: sendTo }, - where: { twitchId: userId } - }); - }), - updateNotification: publicProcedure - .input( - z.object({ - userId: z.string(), - channelIds: z.array(z.string()) - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, channelIds } = input; - - const notification = await ctx.prisma.twitchNotify.update({ - where: { - twitchId: userId - }, - data: { - channelIds - } - }); - - return { notification }; - }), - delete: publicProcedure - .input( - z.object({ - userId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId } = input; - - const notification = await ctx.prisma.twitchNotify.delete({ - where: { - twitchId: userId - } - }); - - return { notification }; - }), - createViaTwitchNotification: publicProcedure - .input( - z.object({ - guildId: z.string(), - userId: z.string(), - ownerId: z.string(), - name: z.string(), - notifyList: z.array(z.string()) - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, userId, ownerId, name, notifyList } = input; - await ctx.prisma.guild.upsert({ - create: { - id: guildId, - notifyList: [userId], - volume: 100, - ownerId: ownerId, - name: name - }, - select: { notifyList: true }, - update: { - notifyList - }, - where: { id: guildId } - }); - }), - updateTwitchNotifications: publicProcedure - .input( - z.object({ - guildId: z.string(), - notifyList: z.array(z.string()) - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, notifyList } = input; - - await ctx.prisma.guild.update({ - where: { id: guildId }, - data: { notifyList } - }); - }), - updateNotificationStatus: publicProcedure - .input( - z.object({ - userId: z.string(), - live: z.boolean(), - sent: z.boolean() - }) - ) - .mutation(async ({ ctx, input }) => { - const { live, sent, userId } = input; - - const notification = await ctx.prisma.twitchNotify.update({ - where: { twitchId: userId }, - data: { live, sent } - }); - - return { notification }; - }) -}); diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts deleted file mode 100644 index 577fa1c6a..000000000 --- a/packages/api/src/routers/user.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const userRouter = createTRPCRouter({ - getUserById: publicProcedure - .input( - z.object({ - id: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { id } = input; - - const user = await ctx.prisma.user.findUnique({ - where: { - discordId: id - } - }); - - return { user }; - }), - create: publicProcedure - .input( - z.object({ - id: z.string(), - name: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, name } = input; - const user = await ctx.prisma.user.upsert({ - where: { - discordId: id - }, - update: {}, - create: { - discordId: id, - name - } - }); - return { user }; - }), - delete: publicProcedure - .input( - z.object({ - id: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - const user = await ctx.prisma.user.delete({ - where: { - discordId: id - } - }); - - return { user }; - }), - updateTimeOffset: publicProcedure - .input( - z.object({ - id: z.string(), - timeOffset: z.number() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, timeOffset } = input; - const userTime = await ctx.prisma.user.update({ - where: { - discordId: id - }, - data: { timeOffset: timeOffset }, - select: { timeOffset: true } - }); - - return { userTime }; - }) -}); diff --git a/packages/api/src/routers/welcome.ts b/packages/api/src/routers/welcome.ts deleted file mode 100644 index 66d15ac5d..000000000 --- a/packages/api/src/routers/welcome.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const welcomeRouter = createTRPCRouter({ - getMessage: publicProcedure - .input( - z.object({ - guildId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { guildId } = input; - - const guild = await ctx.prisma.guild.findUnique({ - where: { - id: guildId - } - }); - - return { - message: guild?.welcomeMessage - }; - }), - setMessage: publicProcedure - .input( - z.object({ - message: z.string().min(4).max(100), - guildId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { message, guildId } = input; - - const guild = await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - welcomeMessage: message - } - }); - - return { guild }; - }), - setChannel: publicProcedure - .input( - z.object({ - channelId: z.string(), - guildId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { channelId, guildId } = input; - - const guild = await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - welcomeMessageChannel: channelId - } - }); - - return { guild }; - }), - getChannel: publicProcedure - .input( - z.object({ - guildId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { guildId } = input; - - const guild = await ctx.prisma.guild.findUnique({ - where: { - id: guildId - }, - select: { - welcomeMessageChannel: true - } - }); - - return { guild }; - }), - getStatus: publicProcedure - .input( - z.object({ - guildId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { guildId } = input; - - const guild = await ctx.prisma.guild.findUnique({ - where: { - id: guildId - }, - select: { - welcomeMessageEnabled: true - } - }); - - return { guild }; - }), - toggle: publicProcedure - .input( - z.object({ - guildId: z.string(), - status: z.boolean() - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, status } = input; - - const guild = await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - welcomeMessageEnabled: status - } - }); - - return { guild }; - }) -}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts deleted file mode 100644 index 7da52871a..000000000 --- a/packages/api/src/trpc.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: - * 1. You want to modify request context (see Part 1) - * 2. You want to create a new middleware or type of procedure (see Part 3) - * - * tl;dr - this is where all the tRPC server stuff is created and plugged in. - * The pieces you will need to use are documented accordingly near the end - */ -import { initTRPC, TRPCError } from '@trpc/server'; -import superjson from 'superjson'; -import { ZodError } from 'zod'; - -import { auth } from '@master-bot/auth'; -import type { Session } from '@master-bot/auth'; -import { prisma } from '@master-bot/db'; - -/** - * 1. CONTEXT - * - * This section defines the "contexts" that are available in the backend API - * - * These allow you to access things like the database, the session, etc, when - * processing a request - * - */ -interface CreateContextOptions { - session: Session | null; -} - -/** - * This helper generates the "internals" for a tRPC context. If you need to use - * it, you can export it from here - * - * Examples of things you may need it for: - * - testing, so we dont have to mock Next.js' req/res - * - trpc's `createSSGHelpers` where we don't have req/res - * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts - */ -const createInnerTRPCContext = (opts: CreateContextOptions) => { - return { - session: opts.session, - prisma - }; -}; - -/** - * This is the actual context you'll use in your router. It will be used to - * process every request that goes through your tRPC endpoint - * @link https://trpc.io/docs/context - */ -export const createTRPCContext = async (opts: { - req?: Request; - auth?: Session; -}) => { - const session = opts.auth ?? (await auth()); - // const source = opts.req?.headers.get('x-trpc-source') ?? 'unknown'; - - // console.log('>>> tRPC Request from', source, 'by', session?.user); - - return createInnerTRPCContext({ - session - }); -}; - -/** - * 2. INITIALIZATION - * - * This is where the trpc api is initialized, connecting the context and - * transformer - */ -const t = initTRPC.context().create({ - transformer: superjson, - errorFormatter({ shape, error }) { - return { - ...shape, - data: { - ...shape.data, - zodError: error.cause instanceof ZodError ? error.cause.flatten() : null - } - }; - } -}); - -/** - * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) - * - * These are the pieces you use to build your tRPC API. You should import these - * a lot in the /src/server/api/routers folder - */ - -/** - * This is how you create new routers and subrouters in your tRPC API - * @see https://trpc.io/docs/router - */ -export const createTRPCRouter = t.router; - -/** - * Public (unauthed) procedure - * - * This is the base piece you use to build new queries and mutations on your - * tRPC API. It does not guarantee that a user querying is authorized, but you - * can still access user session data if they are logged in - */ -export const publicProcedure = t.procedure; - -/** - * Reusable middleware that enforces users are logged in before running the - * procedure - */ -const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { - if (!ctx.session?.user) { - throw new TRPCError({ code: 'UNAUTHORIZED' }); - } - return next({ - ctx: { - // infers the `session` as non-nullable - session: { ...ctx.session, user: ctx.session.user } - } - }); -}); - -/** - * Protected (authed) procedure - * - * If you want a query or mutation to ONLY be accessible to logged in users, use - * this. It verifies the session is valid and guarantees ctx.session.user is not - * null - * - * @see https://trpc.io/docs/procedures - */ -export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); diff --git a/packages/api/src/utils/axiosWithRefresh.ts b/packages/api/src/utils/axiosWithRefresh.ts deleted file mode 100644 index 8e59a4ab1..000000000 --- a/packages/api/src/utils/axiosWithRefresh.ts +++ /dev/null @@ -1,137 +0,0 @@ -import axios, { type AxiosError } from 'axios'; - -import { prisma } from '@master-bot/db'; - -import { env } from '../env.mjs'; - -// const baseURL = 'https://discord.com/api/v10'; // Update to the appropriate Discord API version - -const discordApi = axios.create(); - -async function refreshAccessToken(refreshToken: string, userId: string) { - try { - const params = new URLSearchParams({ - client_id: env.DISCORD_CLIENT_ID, - client_secret: env.DISCORD_CLIENT_SECRET, - grant_type: 'refresh_token', - refresh_token: refreshToken - }); - - let response; - - try { - response = await discordApi.post( - 'https://discord.com/api/v10/oauth2/token', - params, - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - } - } - ); - } catch (error) { - console.error('error in refreshing token', error); - throw error; - } - - const { - access_token, - refresh_token: newRefreshToken, - expires_in - } = response.data; - - // Update the access and refresh tokens in the database - await prisma.account.update({ - where: { - userId - }, - data: { - access_token, - refresh_token: newRefreshToken, - expires_at: expires_in - } - }); - - return { - accessToken: access_token, - refreshToken: newRefreshToken, - expiresIn: expires_in - }; - } catch (error) { - console.error('Error refreshing access token:', error); - return null; - } -} - -async function updateUserTokens( - newTokens: { - accessToken: string; - refreshToken: string; - expiresIn: number; - }, - userId: string -) { - try { - const updatedAccount = await prisma.account.update({ - where: { - userId - }, - data: { - access_token: newTokens.accessToken, - refresh_token: newTokens.refreshToken, - expires_at: newTokens.expiresIn - } - }); - - return updatedAccount; - } catch (error) { - console.error('Error updating user tokens:', error); - return null; - } -} - -discordApi.interceptors.response.use( - response => { - // if response is ok return it - return response; - }, - async (error: Error | AxiosError) => { - if (axios.isAxiosError(error)) { - const originalRequest = error.config; - - if (error.code === 'ERR_BAD_REQUEST') { - const { 'X-User-Id': userId, 'X-Refresh-Token': refreshToken } = - originalRequest!.headers; - - if (typeof userId !== 'string' || typeof refreshToken !== 'string') { - throw error; - } - - const newTokens = await refreshAccessToken(refreshToken, userId); - - if (!newTokens?.accessToken) { - throw error; - } - - // Save the new access token and refresh token to the DB - try { - await updateUserTokens(newTokens, userId); - } catch { - throw error; - } - - // Set the new access token in the header and retry the original request - originalRequest!.headers[ - 'Authorization' - ] = `Bearer ${newTokens.accessToken}`; - - return discordApi(originalRequest!); - } - return Promise.reject(error); - } else { - return Promise.reject(error); - } - } -); - -export { discordApi }; diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json deleted file mode 100644 index 38e6547a4..000000000 --- a/packages/api/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src", "*.ts"] -} diff --git a/packages/auth/env.mjs b/packages/auth/env.mjs deleted file mode 100644 index f768180d6..000000000 --- a/packages/auth/env.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import { createEnv } from '@t3-oss/env-nextjs'; -import { z } from 'zod'; - -export const env = createEnv({ - server: { - DISCORD_CLIENT_ID: z.string().min(1), - DISCORD_CLIENT_SECRET: z.string().min(1), - NEXTAUTH_SECRET: - process.env.NODE_ENV === 'production' - ? z.string().min(1) - : z.string().min(1).optional(), - NEXTAUTH_URL: z.preprocess( - // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL - // Since NextAuth.js automatically uses the VERCEL_URL if present. - str => process.env.VERCEL_URL ?? str, - // VERCEL_URL doesn't include `https` so it cant be validated as a URL - process.env.VERCEL ? z.string() : z.string().url() - ) - }, - client: {}, - runtimeEnv: { - NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, - NEXTAUTH_URL: process.env.NEXTAUTH_URL, - DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, - DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET - }, - skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION -}); diff --git a/packages/auth/index.ts b/packages/auth/index.ts deleted file mode 100644 index 74f41e9da..000000000 --- a/packages/auth/index.ts +++ /dev/null @@ -1,133 +0,0 @@ -// @ts-nocheck -import Discord, { type DiscordProfile } from '@auth/core/providers/discord'; -import type { DefaultSession as DefaultSessionType } from '@auth/core/types'; -import { PrismaAdapter } from '@auth/prisma-adapter'; -import { prisma } from '@master-bot/db'; -import NextAuth from 'next-auth'; - -import { env } from './env.mjs'; - -export type { Session } from 'next-auth'; - -// Update this whenever adding new providers so that the client can -export const providers = ['discord'] as const; -export type OAuthProviders = (typeof providers)[number]; - -declare module 'next-auth' { - interface Session { - user: { - id: string; - discordId: string; - } & DefaultSessionType['user']; - } -} - -const scope = ['identify', 'guilds', 'email'].join(' '); - -export const { - handlers: { GET, POST }, - auth -} = NextAuth({ - adapter: { - ...PrismaAdapter(prisma), - createUser: async data => { - return await prisma.user.upsert({ - where: { discordId: data.discordId }, - update: data, - create: data - }); - } - }, - providers: [ - Discord({ - clientId: env.DISCORD_CLIENT_ID, - clientSecret: env.DISCORD_CLIENT_SECRET, - authorization: { - params: { - scope - } - }, - profile(profile: DiscordProfile) { - return { - id: profile.id, - name: profile.username, - email: profile.email, - image: profile.avatar, - discordId: profile.id - }; - } - }) - ], - callbacks: { - session: async ({ session, user }) => { - const account = await prisma.account.findUnique({ - where: { - userId: user.id - } - }); - - if (account?.expires_at * 1000 < Date.now()) { - // refresh token - try { - const response = await fetch( - 'https://discord.com/api/v10/oauth2/token', - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - method: 'POST', - body: new URLSearchParams({ - grant_type: 'refresh_token', - client_id: env.DISCORD_CLIENT_ID, - client_secret: env.DISCORD_CLIENT_SECRET, - refresh_token: account.refresh_token - }) - } - ); - - if (!response.ok) { - throw new Error('Failed to refresh token'); - } - - const data = await response.json(); - - await prisma.account.update({ - where: { - userId: user.id - }, - data: { - access_token: data.access_token, - refresh_token: data.refresh_token, - expires_at: data.expires_in - } - }); - } catch (error) { - console.log(error); - } - } - - return { - ...session, - user: { - ...session.user, - id: user.id, - discordId: user.discordId - } - }; - } - - // @TODO - if you wanna have auth on the edge - // jwt: ({ token, profile }) => { - // if (profile?.id) { - // token.id = profile.id; - // token.image = profile.picture; - // } - // return token; - // }, - - // @TODO - // authorized({ request, auth }) { - // return !!auth?.user - // } - } -}); diff --git a/packages/auth/package.json b/packages/auth/package.json deleted file mode 100644 index 9d6db2552..000000000 --- a/packages/auth/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@master-bot/auth", - "version": "0.1.0", - "main": "./index.ts", - "types": "./index.ts", - "license": "ISC", - "scripts": { - "clean": "rm -rf .turbo node_modules", - "lint": "eslint .", - "lint:fix": "pnpm lint --fix", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@auth/core": "^0.18.3", - "@auth/prisma-adapter": "^1.0.8", - "@master-bot/db": "^0.1.0", - "@t3-oss/env-nextjs": "^0.7.1", - "next": "^14.0.3", - "next-auth": "5.0.0-beta.3", - "react": "18.2.0", - "react-dom": "18.2.0", - "zod": "^3.22.4" - }, - "devDependencies": { - "@master-bot/eslint-config": "^0.2.0", - "eslint": "^8.54.0", - "typescript": "^5.3.2" - }, - "eslintConfig": { - "root": true, - "extends": [ - "@master-bot/eslint-config/base" - ] - } -} diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json deleted file mode 100644 index 374ae2e29..000000000 --- a/packages/auth/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src", "*.ts", "env.mjs"] -} diff --git a/packages/config/eslint/.eslintrc.cjs b/packages/config/eslint/.eslintrc.cjs new file mode 100644 index 000000000..c629ebb01 --- /dev/null +++ b/packages/config/eslint/.eslintrc.cjs @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + env: { + es2022: true, + node: true + }, + extends: ['eslint:recommended', 'prettier'] +}; diff --git a/packages/config/eslint/base.js b/packages/config/eslint/base.js index 212859d1f..73d060d90 100644 --- a/packages/config/eslint/base.js +++ b/packages/config/eslint/base.js @@ -33,7 +33,6 @@ const config = { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/dot-notation': 'off', - '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-call': 'off' diff --git a/packages/config/eslint/package.json b/packages/config/eslint/package.json index b801a530c..669543158 100644 --- a/packages/config/eslint/package.json +++ b/packages/config/eslint/package.json @@ -1,26 +1,25 @@ { "name": "@master-bot/eslint-config", "version": "0.2.0", + "main": "index.js", "license": "ISC", - "files": [ - "./base.js", - "./nextjs.js", - "./react.js" - ], + "scripts": { + "lint": "eslint ." + }, "dependencies": { - "@next/eslint-plugin-next": "^14.0.3", - "@types/eslint": "^8.44.7", - "@typescript-eslint/eslint-plugin": "^6.12.0", - "@typescript-eslint/parser": "^6.12.0", - "eslint-config-prettier": "^9.0.0", - "eslint-config-turbo": "^1.10.16", - "eslint-plugin-import": "^2.29.0", - "eslint-plugin-jsx-a11y": "^6.8.0", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0" + "@next/eslint-plugin-next": "^15.2.0", + "@types/eslint": "^8.56.12", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-prettier": "^9.1.2", + "eslint-config-turbo": "^1.13.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^4.6.2" }, "devDependencies": { - "eslint": "^8.54.0", - "typescript": "^5.3.2" + "eslint": "^8.57.1", + "typescript": "^5.5.4" } } diff --git a/packages/config/tailwind/package.json b/packages/config/tailwind/package.json index fbfd115b0..fa9e7ef93 100644 --- a/packages/config/tailwind/package.json +++ b/packages/config/tailwind/package.json @@ -1,15 +1,11 @@ { "name": "@master-bot/tailwind-config", "version": "0.1.0", - "main": "index.ts", + "main": "tailwind.config.ts", "license": "ISC", - "files": [ - "index.ts", - "postcss.js" - ], "devDependencies": { - "autoprefixer": "^10.4.16", - "postcss": "^8.4.31", - "tailwindcss": "^3.3.5" + "autoprefixer": "^10.5.4", + "postcss": "^8.5.26", + "tailwindcss": "^3.4.19" } } diff --git a/packages/db/index.ts b/packages/db/index.ts index dc935ee34..6fb4d0af1 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -1,16 +1,15 @@ -import { PrismaClient } from '@prisma/client'; - -export * from '@prisma/client'; - -const globalForPrisma = globalThis as { prisma?: PrismaClient }; - -export const prisma = - globalForPrisma.prisma || - new PrismaClient({ - log: - process.env.NODE_ENV === 'development' - ? ['query', 'error', 'warn'] - : ['error'] - }); - -if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; +export { BotDatabase, setDatabasePath } from './src/database.js'; +export type { + Account, + Guild, + Playlist, + Reminder, + Session, + Song, + SongInput, + TempChannel, + Ticket, + TwitchNotify, + User, + VerificationToken +} from './src/types.js'; diff --git a/packages/db/package.json b/packages/db/package.json index 19eafb99d..e82af6a2f 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,23 +1,20 @@ { "name": "@master-bot/db", "version": "0.1.0", + "private": true, + "type": "module", "main": "./index.ts", "types": "./index.ts", "license": "ISC", "scripts": { - "clean": "rm -rf .turbo node_modules", - "db:generate": "pnpm with-env prisma generate", - "db:push": "pnpm with-env prisma db push --skip-generate", - "db:reset": "pnpm with-env prisma db push --force-reset", - "with-env": "dotenv -e ../../.env --" + "clean": "git clean -xdf .turbo node_modules", + "type-check": "tsc --noEmit" }, - "dependencies": { - "@prisma/client": "^5.6.0" + "engines": { + "node": ">=22.0.0" }, "devDependencies": { - "@types/node": "^20.9.3", - "dotenv-cli": "^7.3.0", - "prisma": "^5.6.0", - "typescript": "^5.3.2" + "@types/node": "^22.5.4", + "typescript": "^5.5.4" } } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma deleted file mode 100644 index aa52b0c2f..000000000 --- a/packages/db/prisma/schema.prisma +++ /dev/null @@ -1,133 +0,0 @@ -generator client { - provider = "prisma-client-js" -} - -datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - shadowDatabaseUrl = env("SHADOW_DB_URL") -} - -// Necessary for Next auth -model Account { - id String @id @default(cuid()) - userId String @unique - type String - provider String - providerAccountId String - refresh_token String? // @db.Text - access_token String? // @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? // @db.Text - session_state String? - user User @relation(fields: [userId], references: [id]) - - @@unique([provider, providerAccountId]) -} - -model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId String - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} - -model User { - id String @id @default(cuid()) - name String? - discordId String @unique - email String? @unique - emailVerified DateTime? - image String? - account Account? - sessions Session[] - playlists Playlist[] - guilds Guild[] - reminders Reminder[] - timeOffset Int? -} - -model VerificationToken { - identifier String - token String @unique - expires DateTime - - @@unique([identifier, token]) -} - -model Song { - id Int @id @default(autoincrement()) - length Int - track String - identifier String - author String - isStream Boolean - position Int - title String - uri String - isSeekable Boolean - sourceName String - thumbnail String - added Int - playlistId Int - playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade) -} - -model Playlist { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - name String - userId String? - user User? @relation(fields: [userId], references: [id]) - songs Song[] -} - -model Guild { - id String @id - name String - added DateTime @default(now()) - volume Int @default(100) - notifyList String[] - ownerId String - owner User @relation(fields: [ownerId], references: [discordId]) - // Settings - disabledCommands String[] @map("disabled_commands") - logChannel String? @map("log_channel") - welcomeMessageChannel String? @map("welcome_message_channel") - welcomeMessage String? @map("welcome_message") - welcomeMessageEnabled Boolean @default(false) @map("welcome_message_enabled") - // Temp Channels - hub String? - hubChannel String? @map("hub_channel") // The channel that users enter to get redirected - tempChannels TempChannel[] -} - -model TempChannel { - id String @id - guildId String - guild Guild @relation(fields: [guildId], references: [id]) - ownerId String @unique -} - -model TwitchNotify { - twitchId String @id - logo String - live Boolean @default(false) - channelIds String[] - sent Boolean -} - -model Reminder { - id Int @id @default(autoincrement()) - createdAt DateTime @default(now()) - repeat String? - event String - description String? - dateTime String - userId String - user User? @relation(fields: [userId], references: [discordId]) - timeOffset Int -} diff --git a/packages/db/src/database.ts b/packages/db/src/database.ts new file mode 100644 index 000000000..753885d46 --- /dev/null +++ b/packages/db/src/database.ts @@ -0,0 +1,1056 @@ +import { DatabaseSync, type SupportedValueType } from 'node:sqlite'; +import fs from 'node:fs'; +import path from 'node:path'; +import type { + Account, + Guild, + Playlist, + Reminder, + Session, + Song, + SongInput, + TempChannel, + Ticket, + TwitchNotify, + User +} from './types.js'; + +let configuredDbPath: string | null = null; + +/** + * Configure the absolute path of the SQLite database file. Must be called once + * before the database is first accessed (e.g. from the bot's env layer). + */ +export function setDatabasePath(dbPath: string): void { + configuredDbPath = dbPath; +} + +function resolveDbPath(): string { + if (configuredDbPath) return configuredDbPath; + const envPath = process.env.DATABASE_PATH ?? process.env.SQLITE_PATH; + if (envPath) return envPath; + return path.resolve(process.cwd(), 'db.sqlite'); +} + +/** + * Hand-rolled SQLite data layer for Master-Bot. + * + * Synchronous, dependency-free node:sqlite database. The schema is defined + * inline in `initSchema()` below (previously Prisma `prisma/schema.prisma`). + * Follows the HELIX BotDatabase pattern: a single process-wide singleton + * exposing typed CRUD methods. The bot selects the file path via env.ts + * `getDbPath()` (DISCORD_DB_PATH or `/data/bot.sqlite`) and calls + * `setDatabasePath()` before first access. + */ +export class BotDatabase { + private static instance: BotDatabase | null = null; + private db: DatabaseSync; + + private constructor() { + const dbPath = resolveDbPath(); + const dir = path.dirname(dbPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + this.db = new DatabaseSync(dbPath); + this.db.exec('PRAGMA journal_mode = WAL;'); + this.db.exec('PRAGMA foreign_keys = ON;'); + this.migrate(); + } + + public static getInstance(): BotDatabase { + if (!BotDatabase.instance) { + BotDatabase.instance = new BotDatabase(); + } + return BotDatabase.instance; + } + + public static resetInstance(): void { + if (BotDatabase.instance) { + BotDatabase.instance.close(); + BotDatabase.instance = null; + } + } + + private migrate(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS "Account" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL UNIQUE, + "type" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerAccountId" TEXT NOT NULL, + "refresh_token" TEXT, + "access_token" TEXT, + "expires_at" INTEGER, + "token_type" TEXT, + "scope" TEXT, + "id_token" TEXT, + "session_state" TEXT, + UNIQUE("provider", "providerAccountId") + ); + CREATE TABLE IF NOT EXISTS "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sessionToken" TEXT NOT NULL UNIQUE, + "userId" TEXT NOT NULL, + "expires" DATETIME NOT NULL + ); + CREATE TABLE IF NOT EXISTS "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT, + "discordId" TEXT NOT NULL UNIQUE, + "email" TEXT UNIQUE, + "emailVerified" DATETIME, + "image" TEXT, + "timeOffset" INTEGER + ); + CREATE TABLE IF NOT EXISTS "VerificationToken" ( + "identifier" TEXT NOT NULL, + "token" TEXT NOT NULL UNIQUE, + "expires" DATETIME NOT NULL, + UNIQUE("identifier", "token") + ); + CREATE TABLE IF NOT EXISTS "Song" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "length" INTEGER NOT NULL, + "track" TEXT NOT NULL, + "identifier" TEXT NOT NULL, + "author" TEXT NOT NULL, + "isStream" BOOLEAN NOT NULL, + "position" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "uri" TEXT NOT NULL, + "isSeekable" BOOLEAN NOT NULL, + "sourceName" TEXT NOT NULL, + "thumbnail" TEXT NOT NULL, + "added" INTEGER NOT NULL, + "playlistId" INTEGER NOT NULL, + FOREIGN KEY ("playlistId") REFERENCES "Playlist" ("id") ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS "Playlist" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "userId" TEXT, + FOREIGN KEY ("userId") REFERENCES "User" ("id") + ); + CREATE TABLE IF NOT EXISTS "Guild" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "added" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "volume" INTEGER NOT NULL DEFAULT 100, + "notifyList" TEXT NOT NULL DEFAULT '[]', + "ownerId" TEXT NOT NULL, + "disabledCommands" TEXT NOT NULL DEFAULT '[]', + "logChannel" TEXT, + "logChannelEnabled" BOOLEAN NOT NULL DEFAULT 0, + "logEvents" TEXT NOT NULL DEFAULT '[]', + "welcomeMessageChannel" TEXT, + "welcomeMessage" TEXT, + "welcomeMessageEnabled" BOOLEAN NOT NULL DEFAULT 0, + "ticketChannel" TEXT, + "ticketTranscriptChannel" TEXT, + "ticketRoleId" TEXT, + "ticketEnabled" BOOLEAN NOT NULL DEFAULT 0, + "ticketMessage" TEXT, + "ticketMessageEnabled" BOOLEAN NOT NULL DEFAULT 0, + "hub" TEXT, + "hubChannel" TEXT + ); + CREATE TABLE IF NOT EXISTS "Ticket" ( + "id" TEXT NOT NULL PRIMARY KEY, + "guildId" TEXT NOT NULL, + "threadId" TEXT NOT NULL UNIQUE, + "creatorId" TEXT NOT NULL, + "closed" BOOLEAN NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "closedAt" DATETIME, + FOREIGN KEY ("guildId") REFERENCES "Guild" ("id") + ); + CREATE TABLE IF NOT EXISTS "TempChannel" ( + "id" TEXT NOT NULL PRIMARY KEY, + "guildId" TEXT NOT NULL, + "ownerId" TEXT NOT NULL UNIQUE, + FOREIGN KEY ("guildId") REFERENCES "Guild" ("id") + ); + CREATE TABLE IF NOT EXISTS "TwitchNotify" ( + "twitchId" TEXT NOT NULL PRIMARY KEY, + "logo" TEXT NOT NULL, + "live" BOOLEAN NOT NULL DEFAULT 0, + "channelIds" TEXT NOT NULL DEFAULT '[]', + "sent" BOOLEAN NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS "Reminder" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "repeat" TEXT, + "event" TEXT NOT NULL, + "description" TEXT, + "dateTime" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "timeOffset" INTEGER NOT NULL + ); + `); + } + + // โ”€โ”€โ”€ generic mapping helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + private all(sql: string, ...params: SupportedValueType[]): T[] { + return this.db.prepare(sql).all(...params) as T[]; + } + + private get(sql: string, ...params: SupportedValueType[]): T | undefined { + return this.db.prepare(sql).get(...params) as T | undefined; + } + + private run(sql: string, ...params: SupportedValueType[]): { lastInsertRowid: number | bigint; changes: number | bigint } { + return this.db.prepare(sql).run(...params); + } + + // โ”€โ”€โ”€ User โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + getUserByDiscordId(discordId: string): User | null { + const row = this.get( + 'SELECT * FROM "User" WHERE "discordId" = ?', + discordId + ); + return row ? this.mapUser(row) : null; + } + + getUserById(id: string): User | null { + const row = this.get('SELECT * FROM "User" WHERE "id" = ?', id); + return row ? this.mapUser(row) : null; + } + + upsertUser(discordId: string, name: string): User { + const existing = this.getUserByDiscordId(discordId); + if (existing) { + this.run( + 'UPDATE "User" SET "name" = ? WHERE "discordId" = ?', + name, + discordId + ); + return this.getUserByDiscordId(discordId)!; + } + const id = this.generateCuid(); + this.run( + 'INSERT INTO "User" ("id", "name", "discordId") VALUES (?, ?, ?)', + id, + name, + discordId + ); + return this.getUserByDiscordId(discordId)!; + } + + deleteUserByDiscordId(discordId: string): User | null { + const existing = this.getUserByDiscordId(discordId); + if (!existing) return null; + this.run('DELETE FROM "User" WHERE "discordId" = ?', discordId); + return existing; + } + + updateTimeOffset(discordId: string, timeOffset: number): number { + this.run( + 'UPDATE "User" SET "timeOffset" = ? WHERE "discordId" = ?', + timeOffset, + discordId + ); + const user = this.getUserByDiscordId(discordId); + return user?.timeOffset ?? timeOffset; + } + + // โ”€โ”€โ”€ Account (NextAuth/OAuth tokens) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + getAccountsByUserId(userId: string): Account[] { + return this.all( + 'SELECT * FROM "Account" WHERE "userId" = ?', + userId + ).map(this.mapAccount); + } + + getAccountByUserId(userId: string): Account | null { + const row = this.get('SELECT * FROM "Account" WHERE "userId" = ?', userId); + return row ? this.mapAccount(row) : null; + } + + createAccount(data: Omit): Account { + const id = this.generateCuid(); + this.run( + `INSERT INTO "Account" ( + "id", "userId", "type", "provider", "providerAccountId", + "refresh_token", "access_token", "expires_at", "token_type", + "scope", "id_token", "session_state" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, + data.userId, + data.type, + data.provider, + data.providerAccountId, + data.refresh_token, + data.access_token, + data.expires_at, + data.token_type, + data.scope, + data.id_token, + data.session_state + ); + return this.getAccountByUserId(data.userId)!; + } + + updateAccountTokens( + userId: string, + data: { + access_token?: string | null; + refresh_token?: string | null; + expires_at?: number | null; + id_token?: string | null; + scope?: string | null; + token_type?: string | null; + } + ): void { + const sets: string[] = []; + const params: SupportedValueType[] = []; + for (const [key, value] of Object.entries(data)) { + sets.push(`"${key}" = ?`); + params.push(value); + } + if (sets.length > 0) { + params.push(userId); + this.run(`UPDATE "Account" SET ${sets.join(', ')} WHERE "userId" = ?`, ...params); + } + } + + // โ”€โ”€โ”€ Session โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + getSessionByToken(sessionToken: string): Session | null { + const row = this.get( + 'SELECT * FROM "Session" WHERE "sessionToken" = ?', + sessionToken + ); + return row ? this.mapSession(row) : null; + } + + createSession(sessionToken: string, userId: string, expires: Date): Session { + const id = this.generateCuid(); + this.run( + 'INSERT INTO "Session" ("id", "sessionToken", "userId", "expires") VALUES (?, ?, ?, ?)', + id, + sessionToken, + userId, + expires.toISOString() + ); + return this.getSessionByToken(sessionToken)!; + } + + deleteSession(sessionToken: string): void { + this.run('DELETE FROM "Session" WHERE "sessionToken" = ?', sessionToken); + } + + // โ”€โ”€โ”€ Guild โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + getGuild(id: string): Guild | null { + const row = this.get('SELECT * FROM "Guild" WHERE "id" = ?', id); + return row ? this.mapGuild(row) : null; + } + + upsertGuild(id: string, ownerId: string, name: string): Guild { + const existing = this.getGuild(id); + if (existing) return existing; + this.run( + 'INSERT INTO "Guild" ("id", "name", "volume", "ownerId") VALUES (?, ?, 100, ?)', + id, + name, + ownerId + ); + return this.getGuild(id)!; + } + + upsertGuildFull( + id: string, + ownerId: string, + name: string, + notifyList: string[] + ): Guild { + const existing = this.getGuild(id); + if (existing) { + this.run( + 'UPDATE "Guild" SET "notifyList" = ? WHERE "id" = ?', + JSON.stringify(notifyList), + id + ); + return this.getGuild(id)!; + } + this.run( + 'INSERT INTO "Guild" ("id", "name", "volume", "ownerId", "notifyList") VALUES (?, ?, 100, ?, ?)', + id, + name, + ownerId, + JSON.stringify(notifyList) + ); + return this.getGuild(id)!; + } + + deleteGuild(id: string): Guild | null { + const existing = this.getGuild(id); + if (!existing) return null; + this.run('DELETE FROM "Guild" WHERE "id" = ?', id); + return existing; + } + + getGuildsByOwner(ownerDiscordId: string): Guild[] { + return this.all( + 'SELECT * FROM "Guild" WHERE "ownerId" = ?', + ownerDiscordId + ).map(this.mapGuild); + } + + getAllGuilds(): Guild[] { + return this.all('SELECT * FROM "Guild"').map(this.mapGuild); + } + + updateGuildVolume(guildId: string, volume: number): Guild | null { + this.run('UPDATE "Guild" SET "volume" = ? WHERE "id" = ?', volume, guildId); + return this.getGuild(guildId); + } + + setGuildLogChannel(guildId: string, channelId: string | null): Guild | null { + this.run( + 'UPDATE "Guild" SET "logChannel" = ?, "logChannelEnabled" = ? WHERE "id" = ?', + channelId, + channelId ? 1 : 0, + guildId + ); + return this.getGuild(guildId); + } + + toggleGuildLogChannel(guildId: string, status: boolean): Guild | null { + this.run( + 'UPDATE "Guild" SET "logChannelEnabled" = ? WHERE "id" = ?', + status ? 1 : 0, + guildId + ); + return this.getGuild(guildId); + } + + updateGuildLogEvents(guildId: string, events: string[]): Guild | null { + this.run( + 'UPDATE "Guild" SET "logEvents" = ? WHERE "id" = ?', + JSON.stringify(events), + guildId + ); + return this.getGuild(guildId); + } + + // โ”€โ”€โ”€ Welcome โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + setWelcomeMessage(guildId: string, message: string): Guild | null { + this.run( + 'UPDATE "Guild" SET "welcomeMessage" = ? WHERE "id" = ?', + message, + guildId + ); + return this.getGuild(guildId); + } + + setWelcomeChannel(guildId: string, channelId: string): Guild | null { + this.run( + 'UPDATE "Guild" SET "welcomeMessageChannel" = ? WHERE "id" = ?', + channelId, + guildId + ); + return this.getGuild(guildId); + } + + toggleWelcome(guildId: string, status: boolean): Guild | null { + this.run( + 'UPDATE "Guild" SET "welcomeMessageEnabled" = ? WHERE "id" = ?', + status ? 1 : 0, + guildId + ); + return this.getGuild(guildId); + } + + // โ”€โ”€โ”€ Tickets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + setTicketChannel(guildId: string, channelId: string | null): Guild | null { + this.run( + 'UPDATE "Guild" SET "ticketChannel" = ?, "ticketEnabled" = ? WHERE "id" = ?', + channelId, + channelId ? 1 : 0, + guildId + ); + return this.getGuild(guildId); + } + + setTicketTranscriptChannel(guildId: string, channelId: string | null): Guild | null { + this.run( + 'UPDATE "Guild" SET "ticketTranscriptChannel" = ? WHERE "id" = ?', + channelId, + guildId + ); + return this.getGuild(guildId); + } + + setTicketRole(guildId: string, roleId: string | null): Guild | null { + this.run( + 'UPDATE "Guild" SET "ticketRoleId" = ? WHERE "id" = ?', + roleId, + guildId + ); + return this.getGuild(guildId); + } + + toggleTicket(guildId: string, status: boolean): Guild | null { + this.run( + 'UPDATE "Guild" SET "ticketEnabled" = ? WHERE "id" = ?', + status ? 1 : 0, + guildId + ); + return this.getGuild(guildId); + } + + setTicketMessage(guildId: string, message: string): Guild | null { + this.run( + 'UPDATE "Guild" SET "ticketMessage" = ? WHERE "id" = ?', + message, + guildId + ); + return this.getGuild(guildId); + } + + // โ”€โ”€โ”€ Hub / Temp Channels โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + setHub(guildId: string, hub: string | null, hubChannel: string | null): Guild | null { + this.run( + 'UPDATE "Guild" SET "hub" = ?, "hubChannel" = ? WHERE "id" = ?', + hub, + hubChannel, + guildId + ); + return this.getGuild(guildId); + } + + getTempChannel(guildId: string, ownerId: string): TempChannel | null { + const row = this.get( + 'SELECT * FROM "TempChannel" WHERE "guildId" = ? AND "ownerId" = ?', + guildId, + ownerId + ); + return row ? this.mapTempChannel(row) : null; + } + + createTempChannel(guildId: string, ownerId: string, channelId: string): TempChannel { + this.run( + 'INSERT INTO "TempChannel" ("id", "guildId", "ownerId") VALUES (?, ?, ?)', + channelId, + guildId, + ownerId + ); + return this.getTempChannel(guildId, ownerId)!; + } + + deleteTempChannelByChannelId(channelId: string): TempChannel | null { + const row = this.get( + 'SELECT * FROM "TempChannel" WHERE "id" = ?', + channelId + ); + if (!row) return null; + this.run('DELETE FROM "TempChannel" WHERE "id" = ?', channelId); + return this.mapTempChannel(row); + } + + // โ”€โ”€โ”€ Playlists + Songs โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + getPlaylist(userId: string, name: string): (Playlist & { songs: Song[] }) | null { + const row = this.get( + 'SELECT * FROM "Playlist" WHERE "name" = ? AND "userId" = ?', + name, + userId + ); + if (!row) return null; + const playlist = this.mapPlaylist(row); + playlist.songs = this.getSongsForPlaylist(playlist.id); + return playlist; + } + + getAllPlaylists(userId: string): (Playlist & { songs: Song[] })[] { + const rows = this.all( + 'SELECT * FROM "Playlist" WHERE "userId" = ? ORDER BY "id" ASC', + userId + ); + return rows.map(row => { + const playlist = this.mapPlaylist(row); + playlist.songs = this.getSongsForPlaylist(playlist.id); + return playlist; + }); + } + + createPlaylist(userId: string, name: string): Playlist { + const result = this.run( + 'INSERT INTO "Playlist" ("name", "userId") VALUES (?, ?)', + name, + userId + ); + const id = Number(result.lastInsertRowid); + return this.mapPlaylist( + this.get('SELECT * FROM "Playlist" WHERE "id" = ?', id)! + ); + } + + deletePlaylist(userId: string, name: string): { count: number } { + const result = this.run( + 'DELETE FROM "Playlist" WHERE "userId" = ? AND "name" = ?', + userId, + name + ); + return { count: Number(result.changes) }; + } + + private getSongsForPlaylist(playlistId: number): Song[] { + return this.all( + 'SELECT * FROM "Song" WHERE "playlistId" = ? ORDER BY "position" ASC', + playlistId + ).map(this.mapSong); + } + + createSongs(songs: SongInput[]): { count: number } { + const stmt = this.db.prepare( + `INSERT INTO "Song" ( + "length", "track", "identifier", "author", "isStream", "position", + "title", "uri", "isSeekable", "sourceName", "thumbnail", "added", "playlistId" + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ); + let count = 0; + for (const s of songs) { + stmt.run( + s.length, + s.track, + s.identifier, + s.author, + s.isStream ? 1 : 0, + s.position, + s.title, + s.uri, + s.isSeekable ? 1 : 0, + s.sourceName, + s.thumbnail, + s.added, + s.playlistId + ); + count++; + } + return { count }; + } + + deleteSong(id: number): Song | null { + const row = this.get('SELECT * FROM "Song" WHERE "id" = ?', id); + if (!row) return null; + this.run('DELETE FROM "Song" WHERE "id" = ?', id); + return this.mapSong(row); + } + + getAllSongs(): Song[] { + return this.all('SELECT * FROM "Song"').map(this.mapSong); + } + + // โ”€โ”€โ”€ Twitch Notify โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + getAllTwitchNotifications(): TwitchNotify[] { + return this.all('SELECT * FROM "TwitchNotify"').map(this.mapTwitchNotify); + } + + getTwitchNotification(twitchId: string): TwitchNotify | null { + const row = this.get( + 'SELECT * FROM "TwitchNotify" WHERE "twitchId" = ?', + twitchId + ); + return row ? this.mapTwitchNotify(row) : null; + } + + upsertTwitchNotification( + twitchId: string, + logo: string, + sendTo: string[], + initialChannelId?: string + ): void { + const existing = this.getTwitchNotification(twitchId); + if (existing) { + this.run( + 'UPDATE "TwitchNotify" SET "channelIds" = ? WHERE "twitchId" = ?', + JSON.stringify(sendTo), + twitchId + ); + } else { + this.run( + 'INSERT INTO "TwitchNotify" ("twitchId", "logo", "channelIds", "sent") VALUES (?, ?, ?, 0)', + twitchId, + logo, + JSON.stringify(initialChannelId ? [initialChannelId] : sendTo) + ); + } + } + + updateTwitchNotification(twitchId: string, channelIds: string[]): TwitchNotify | null { + this.run( + 'UPDATE "TwitchNotify" SET "channelIds" = ? WHERE "twitchId" = ?', + JSON.stringify(channelIds), + twitchId + ); + return this.getTwitchNotification(twitchId); + } + + deleteTwitchNotification(twitchId: string): TwitchNotify | null { + const existing = this.getTwitchNotification(twitchId); + if (!existing) return null; + this.run('DELETE FROM "TwitchNotify" WHERE "twitchId" = ?', twitchId); + return existing; + } + + updateTwitchNotificationStatus(twitchId: string, live: boolean, sent: boolean): TwitchNotify | null { + this.run( + 'UPDATE "TwitchNotify" SET "live" = ?, "sent" = ? WHERE "twitchId" = ?', + live ? 1 : 0, + sent ? 1 : 0, + twitchId + ); + return this.getTwitchNotification(twitchId); + } + + // โ”€โ”€โ”€ Tickets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + getRecentTickets(guildId: string, take = 10): Ticket[] { + return this.all( + 'SELECT * FROM "Ticket" WHERE "guildId" = ? ORDER BY "createdAt" DESC LIMIT ?', + guildId, + take + ).map(this.mapTicket); + } + + getActiveTickets(guildId: string): Ticket[] { + return this.all( + 'SELECT * FROM "Ticket" WHERE "guildId" = ? AND "closed" = 0 ORDER BY "createdAt" DESC', + guildId + ).map(this.mapTicket); + } + + createTicket(guildId: string, threadId: string, creatorId: string): Ticket { + const id = this.generateCuid(); + this.run( + 'INSERT INTO "Ticket" ("id", "guildId", "threadId", "creatorId") VALUES (?, ?, ?, ?)', + id, + guildId, + threadId, + creatorId + ); + return this.getTicketByThreadId(threadId)!; + } + + getTicketByThreadId(threadId: string): Ticket | null { + const row = this.get( + 'SELECT * FROM "Ticket" WHERE "threadId" = ?', + threadId + ); + return row ? this.mapTicket(row) : null; + } + + closeTicket(threadId: string): Ticket | null { + this.run( + 'UPDATE "Ticket" SET "closed" = 1, "closedAt" = ? WHERE "threadId" = ?', + new Date().toISOString(), + threadId + ); + return this.getTicketByThreadId(threadId); + } + + // โ”€โ”€โ”€ Reminders โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + getAllReminders(): Reminder[] { + return this.all('SELECT * FROM "Reminder"').map(this.mapReminder); + } + + getDueReminders(beforeIsoDate: string): Reminder[] { + return this.all( + 'SELECT * FROM "Reminder" WHERE "dateTime" <= ? ORDER BY "dateTime" ASC', + beforeIsoDate + ).map(this.mapReminder); + } + + getRemindersByUser(userId: string): Reminder[] { + return this.all( + 'SELECT * FROM "Reminder" WHERE "userId" = ? ORDER BY "dateTime" ASC', + userId + ).map(this.mapReminder); + } + + getReminderByUserAndEvent(userId: string, event: string): Reminder | null { + const row = this.get( + 'SELECT * FROM "Reminder" WHERE "userId" = ? AND "event" = ?', + userId, + event + ); + return row ? this.mapReminder(row) : null; + } + + createReminder(data: { + userId: string; + event: string; + description: string | null; + dateTime: string; + repeat: string | null; + timeOffset: number; + }): Reminder { + const result = this.run( + 'INSERT INTO "Reminder" ("event", "description", "dateTime", "repeat", "userId", "timeOffset") VALUES (?, ?, ?, ?, ?, ?)', + data.event, + data.description, + data.dateTime, + data.repeat, + data.userId, + data.timeOffset + ); + const id = Number(result.lastInsertRowid); + return this.mapReminder( + this.get('SELECT * FROM "Reminder" WHERE "id" = ?', id)! + ); + } + + deleteRemindersByUserAndEvent(userId: string, event: string): { count: number } { + const result = this.run( + 'DELETE FROM "Reminder" WHERE "userId" = ? AND "event" = ?', + userId, + event + ); + return { count: Number(result.changes) }; + } + + deleteReminderById(id: number, userId: string): { count: number } { + const result = this.run( + 'DELETE FROM "Reminder" WHERE "id" = ? AND "userId" = ?', + id, + userId + ); + return { count: Number(result.changes) }; + } + + getRemindersByUserIdSelect(userId: string): { id: number; event: string; dateTime: string; description: string | null }[] { + return this.all( + 'SELECT "id", "event", "dateTime", "description" FROM "Reminder" WHERE "userId" = ? ORDER BY "dateTime" ASC', + userId + ).map(row => ({ + id: Number(row.id), + event: row.event, + dateTime: row.dateTime, + description: row.description + })); + } + + // โ”€โ”€โ”€ Command (disabled commands) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + toggleDisabledCommand(guildId: string, commandId: string, status: boolean): Guild | null { + const guild = this.getGuild(guildId); + if (!guild) return null; + const current: string[] = this.parseJsonArray(guild.disabledCommands); + let updated: string[]; + if (status) { + updated = Array.from(new Set([...current, commandId])); + } else { + updated = current.filter(cid => cid !== commandId); + } + this.run( + 'UPDATE "Guild" SET "disabledCommands" = ? WHERE "id" = ?', + JSON.stringify(updated), + guildId + ); + return this.getGuild(guildId); + } + + // โ”€โ”€โ”€ Stats / Health โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + getStats(): { + guildCount: number; + userCount: number; + playlistCount: number; + songCount: number; + ticketCount: number; + tempChannelCount: number; + twitchNotifyCount: number; + reminderCount: number; + sizeBytes: number; + } { + return { + guildCount: this.countRows('Guild'), + userCount: this.countRows('User'), + playlistCount: this.countRows('Playlist'), + songCount: this.countRows('Song'), + ticketCount: this.countRows('Ticket'), + tempChannelCount: this.countRows('TempChannel'), + twitchNotifyCount: this.countRows('TwitchNotify'), + reminderCount: this.countRows('Reminder'), + sizeBytes: this.fileSize() + }; + } + + ping(): boolean { + try { + this.db.prepare('SELECT 1').get(); + return true; + } catch { + return false; + } + } + + private countRows(table: string): number { + const row = this.get(`SELECT COUNT(*) AS c FROM "${table}"`); + return Number(row?.c ?? 0); + } + + private fileSize(): number { + try { + return fs.statSync(resolveDbPath()).size; + } catch { + return 0; + } + } + + // โ”€โ”€โ”€ row mappers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + private mapUser = (r: any): User => ({ + id: r.id, + name: r.name, + discordId: r.discordId, + email: r.email, + emailVerified: r.emailVerified, + image: r.image, + timeOffset: r.timeOffset + }); + + private mapAccount = (r: any): Account => ({ + id: r.id, + userId: r.userId, + type: r.type, + provider: r.provider, + providerAccountId: r.providerAccountId, + refresh_token: r.refresh_token, + access_token: r.access_token, + expires_at: r.expires_at, + token_type: r.token_type, + scope: r.scope, + id_token: r.id_token, + session_state: r.session_state + }); + + private mapSession = (r: any): Session => ({ + id: r.id, + sessionToken: r.sessionToken, + userId: r.userId, + expires: r.expires + }); + + private mapGuild = (r: any): Guild => ({ + id: r.id, + name: r.name, + added: r.added, + volume: Number(r.volume), + notifyList: r.notifyList, + ownerId: r.ownerId, + disabledCommands: r.disabledCommands, + logChannel: r.logChannel, + logChannelEnabled: Boolean(r.logChannelEnabled), + logEvents: r.logEvents, + welcomeMessageChannel: r.welcomeMessageChannel, + welcomeMessage: r.welcomeMessage, + welcomeMessageEnabled: Boolean(r.welcomeMessageEnabled), + ticketChannel: r.ticketChannel, + ticketTranscriptChannel: r.ticketTranscriptChannel, + ticketRoleId: r.ticketRoleId, + ticketEnabled: Boolean(r.ticketEnabled), + ticketMessage: r.ticketMessage, + ticketMessageEnabled: Boolean(r.ticketMessageEnabled), + hub: r.hub, + hubChannel: r.hubChannel + }); + + private mapSong = (r: any): Song => ({ + id: Number(r.id), + length: Number(r.length), + track: r.track, + identifier: r.identifier, + author: r.author, + isStream: Boolean(r.isStream), + position: Number(r.position), + title: r.title, + uri: r.uri, + isSeekable: Boolean(r.isSeekable), + sourceName: r.sourceName, + thumbnail: r.thumbnail, + added: Number(r.added), + playlistId: Number(r.playlistId) + }); + + private mapPlaylist = (r: any): Playlist => ({ + id: Number(r.id), + createdAt: r.createdAt, + name: r.name, + userId: r.userId, + songs: [] + }); + + private mapTicket = (r: any): Ticket => ({ + id: r.id, + guildId: r.guildId, + threadId: r.threadId, + creatorId: r.creatorId, + closed: Boolean(r.closed), + createdAt: r.createdAt, + closedAt: r.closedAt + }); + + private mapTempChannel = (r: any): TempChannel => ({ + id: r.id, + guildId: r.guildId, + ownerId: r.ownerId + }); + + private mapTwitchNotify = (r: any): TwitchNotify => ({ + twitchId: r.twitchId, + logo: r.logo, + live: Boolean(r.live), + channelIds: r.channelIds, + sent: Boolean(r.sent) + }); + + private mapReminder = (r: any): Reminder => ({ + id: Number(r.id), + createdAt: r.createdAt, + repeat: r.repeat, + event: r.event, + description: r.description, + dateTime: r.dateTime, + userId: r.userId, + timeOffset: Number(r.timeOffset) + }); + + private parseJsonArray(raw: string): string[] { + try { + const parsed = JSON.parse(raw || '[]'); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + private generateCuid(): string { + const ts = Date.now().toString(36); + const rand = Math.random().toString(36).slice(2, 10); + const rand2 = Math.random().toString(36).slice(2, 10); + return `c${ts}${rand}${rand2}`; + } + + public close(): void { + try { + this.db.close(); + } catch { + // ignore + } + } +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts new file mode 100644 index 000000000..f43b28534 --- /dev/null +++ b/packages/db/src/types.ts @@ -0,0 +1,137 @@ +export interface Account { + id: string; + userId: string; + type: string; + provider: string; + providerAccountId: string; + refresh_token: string | null; + access_token: string | null; + expires_at: number | null; + token_type: string | null; + scope: string | null; + id_token: string | null; + session_state: string | null; +} + +export interface Session { + id: string; + sessionToken: string; + userId: string; + expires: string; +} + +export interface User { + id: string; + name: string | null; + discordId: string; + email: string | null; + emailVerified: string | null; + image: string | null; + timeOffset: number | null; +} + +export interface VerificationToken { + identifier: string; + token: string; + expires: string; +} + +export interface Song { + id: number; + length: number; + track: string; + identifier: string; + author: string; + isStream: boolean; + position: number; + title: string; + uri: string; + isSeekable: boolean; + sourceName: string; + thumbnail: string; + added: number; + playlistId: number; +} + +export interface Playlist { + id: number; + createdAt: string; + name: string; + userId: string | null; + songs: Song[]; +} + +export interface Guild { + id: string; + name: string; + added: string; + volume: number; + notifyList: string; + ownerId: string; + disabledCommands: string; + logChannel: string | null; + logChannelEnabled: boolean; + logEvents: string; + welcomeMessageChannel: string | null; + welcomeMessage: string | null; + welcomeMessageEnabled: boolean; + ticketChannel: string | null; + ticketTranscriptChannel: string | null; + ticketRoleId: string | null; + ticketEnabled: boolean; + ticketMessage: string | null; + ticketMessageEnabled: boolean; + hub: string | null; + hubChannel: string | null; +} + +export interface Ticket { + id: string; + guildId: string; + threadId: string; + creatorId: string; + closed: boolean; + createdAt: string; + closedAt: string | null; +} + +export interface TempChannel { + id: string; + guildId: string; + ownerId: string; +} + +export interface TwitchNotify { + twitchId: string; + logo: string; + live: boolean; + channelIds: string; + sent: boolean; +} + +export interface Reminder { + id: number; + createdAt: string; + repeat: string | null; + event: string; + description: string | null; + dateTime: string; + userId: string; + timeOffset: number; +} + +export interface SongInput { + length: number; + track: string; + identifier: string; + author: string; + isStream: boolean; + position: number; + title: string; + uri: string; + isSeekable: boolean; + sourceName: string; + thumbnail: string; + added: number; + playlistId: number; +} diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index c313580d4..43541b741 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,4 +1,12 @@ { "extends": "../../tsconfig.json", - "include": ["index.ts"] + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": ".", + "noEmit": false, + "declaration": true + }, + "include": ["index.ts", "src/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6ca73205..493c86071 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,52 +7,61 @@ settings: importers: .: - dependencies: + devDependencies: '@ianvs/prettier-plugin-sort-imports': - specifier: ^4.1.1 - version: 4.1.1(prettier@3.1.0) + specifier: ^4.7.1 + version: 4.7.1(prettier@3.9.6) '@manypkg/cli': - specifier: ^0.21.0 - version: 0.21.0 + specifier: ^0.25.1 + version: 0.25.1 + '@types/node': + specifier: ^22.5.4 + version: 22.5.4 + '@vitest/coverage-v8': + specifier: ^4.1.0 + version: 4.1.0(vitest@4.1.0) prettier: - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^3.9.6 + version: 3.9.6 prettier-plugin-tailwindcss: - specifier: ^0.5.7 - version: 0.5.7(@ianvs/prettier-plugin-sort-imports@4.1.1)(prettier@3.1.0) + specifier: ^0.8.1 + version: 0.8.1(@ianvs/prettier-plugin-sort-imports@4.7.1)(prettier@3.9.6) + tsx: + specifier: ^4.19.1 + version: 4.19.1 turbo: - specifier: ^1.10.16 - version: 1.10.16 + specifier: ^1.13.4 + version: 1.13.4 typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.5.4 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@22.5.4)(vite@8.2.2) apps/bot: dependencies: '@discordjs/collection': - specifier: ^2.0.0 - version: 2.0.0 - '@lavaclient/spotify': - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^2.1.1 + version: 2.1.1 '@lavalink/encoding': specifier: ^0.1.2 version: 0.1.2 - '@master-bot/api': + '@master-bot/dashboard': + specifier: ^1.0.0 + version: link:../dashboard + '@master-bot/db': specifier: ^0.1.0 - version: link:../../packages/api + version: link:../../packages/db '@napi-rs/canvas': - specifier: ^0.1.44 - version: 0.1.44 - '@prisma/client': - specifier: ^5.6.0 - version: 5.6.0(prisma@5.6.0) + specifier: ^1.0.8 + version: 1.0.8 '@sapphire/decorators': - specifier: ^6.0.2 - version: 6.0.2 + specifier: ^6.2.0 + version: 6.2.0 '@sapphire/discord.js-utilities': - specifier: ^7.1.2 - version: 7.1.2 + specifier: ^7.3.3 + version: 7.3.3 '@sapphire/framework': specifier: ^4.8.2 version: 4.8.2 @@ -60,380 +69,158 @@ importers: specifier: ^2.0.3 version: 2.0.3 '@sapphire/time-utilities': - specifier: ^1.7.10 - version: 1.7.10 + specifier: ^1.7.14 + version: 1.7.14 '@sapphire/utilities': - specifier: ^3.13.0 - version: 3.13.0 - '@t3-oss/env-core': - specifier: ^0.7.1 - version: 0.7.1(typescript@5.3.2)(zod@3.22.4) - '@trpc/client': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106) - '@trpc/server': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106 + specifier: ^3.18.2 + version: 3.18.2 axios: - specifier: ^1.6.2 - version: 1.6.2 + specifier: ^1.20.0 + version: 1.20.0 colorette: specifier: ^2.0.20 version: 2.0.20 discord.js: - specifier: ^14.14.1 - version: 14.14.1 + specifier: ^14.27.0 + version: 14.27.0 + dotenv: + specifier: ^16.6.1 + version: 16.6.1 genius-discord-lyrics: specifier: 1.0.5 version: 1.0.5 google-translate-api-x: - specifier: ^10.6.7 - version: 10.6.7 - ioredis: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^10.7.3 + version: 10.7.3 iso-639-1: - specifier: ^3.1.0 - version: 3.1.0 - lavaclient: - specifier: ^4.1.1 - version: 4.1.1 + specifier: ^3.1.6 + version: 3.1.6 + lavalink-client: + specifier: 2.2.0 + version: 2.2.0 metadata-filter: specifier: ^1.3.0 version: 1.3.0 ncp: specifier: ^2.0.0 version: 2.0.0 - node-fetch: - specifier: ^3.3.2 - version: 3.3.2 npm-run-all: specifier: ^4.1.5 version: 4.1.5 + picocolors: + specifier: ^1.1.0 + version: 1.1.1 string-progressbar: specifier: ^1.0.4 version: 1.0.4 - superjson: - specifier: 1.13.3 - version: 1.13.3 winston: - specifier: ^3.11.0 - version: 3.11.0 + specifier: ^3.19.0 + version: 3.19.0 winston-daily-rotate-file: - specifier: ^4.7.1 - version: 4.7.1(winston@3.11.0) - zod: - specifier: ^3.22.4 - version: 3.22.4 + specifier: ^5.0.0 + version: 5.0.0(winston@3.19.0) devDependencies: - '@lavaclient/types': - specifier: ^2.1.1 - version: 2.1.1 '@sapphire/ts-config': - specifier: ^5.0.0 - version: 5.0.0 - '@types/ioredis': - specifier: ^4.28.10 - version: 4.28.10 + specifier: ^5.0.3 + version: 5.0.3 '@types/node': - specifier: ^20.9.3 - version: 20.9.3 + specifier: ^22.5.4 + version: 22.5.4 '@typescript-eslint/eslint-plugin': - specifier: ^6.12.0 - version: 6.12.0(@typescript-eslint/parser@6.12.0)(eslint@8.54.0)(typescript@5.3.2) + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^6.12.0 - version: 6.12.0(eslint@8.54.0)(typescript@5.3.2) - dotenv: - specifier: ^16.3.1 - version: 16.3.1 + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) dotenv-cli: - specifier: ^7.3.0 - version: 7.3.0 + specifier: ^7.4.4 + version: 7.4.4 prettier: - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^3.9.6 + version: 3.9.6 tslib: - specifier: ^2.6.2 - version: 2.6.2 + specifier: ^2.8.1 + version: 2.8.1 typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.5.4 + version: 5.9.3 apps/dashboard: dependencies: - '@master-bot/api': - specifier: ^0.1.0 - version: link:../../packages/api - '@master-bot/auth': - specifier: ^0.1.0 - version: link:../../packages/auth '@master-bot/db': specifier: ^0.1.0 version: link:../../packages/db - '@radix-ui/react-dropdown-menu': - specifier: ^2.0.6 - version: 2.0.6(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-select': - specifier: ^2.0.0 - version: 2.0.0(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-slot': - specifier: ^1.0.2 - version: 1.0.2(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-switch': - specifier: ^1.0.3 - version: 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-toast': - specifier: ^1.1.5 - version: 1.1.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@t3-oss/env-nextjs': - specifier: ^0.7.1 - version: 0.7.1(typescript@5.3.2)(zod@3.22.4) - '@tanstack/react-query': - specifier: ^5.8.4 - version: 5.8.4(react-dom@18.2.0)(react@18.2.0) - '@tanstack/react-query-devtools': - specifier: ^5.8.4 - version: 5.8.4(@tanstack/react-query@5.8.4)(react-dom@18.2.0)(react@18.2.0) - '@tanstack/react-query-next-experimental': - specifier: 5.8.4 - version: 5.8.4(@tanstack/react-query@5.8.4)(next@14.0.3)(react-dom@18.2.0)(react@18.2.0) - '@trpc/client': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106) - '@trpc/next': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106(@tanstack/react-query@5.8.4)(@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/react-query@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106)(next@14.0.3)(react-dom@18.2.0)(react@18.2.0) - '@trpc/react-query': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106(@tanstack/react-query@5.8.4)(@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106)(react-dom@18.2.0)(react@18.2.0) - '@trpc/server': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106 - class-variance-authority: - specifier: ^0.7.0 - version: 0.7.0 - clsx: - specifier: ^2.0.0 - version: 2.0.0 - discord-api-types: - specifier: ^0.37.64 - version: 0.37.64 - lucide-react: - specifier: ^0.292.0 - version: 0.292.0(react@18.2.0) - next: - specifier: ^14.0.3 - version: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - next-themes: - specifier: ^0.2.1 - version: 0.2.1(next@14.0.3)(react-dom@18.2.0)(react@18.2.0) - react: - specifier: 18.2.0 - version: 18.2.0 - react-dom: - specifier: 18.2.0 - version: 18.2.0(react@18.2.0) - superjson: - specifier: 1.13.3 - version: 1.13.3 - tailwind-merge: - specifier: ^2.0.0 - version: 2.0.0 - tailwindcss-animate: - specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.3.5) - zod: - specifier: ^3.22.4 - version: 3.22.4 + picocolors: + specifier: ^1.1.0 + version: 1.1.1 devDependencies: - '@master-bot/eslint-config': - specifier: ^0.2.0 - version: link:../../packages/config/eslint - '@master-bot/tailwind-config': - specifier: ^0.1.0 - version: link:../../packages/config/tailwind '@types/node': - specifier: ^20.9.3 - version: 20.9.3 - '@types/react': - specifier: ^18.2.38 - version: 18.2.38 - '@types/react-dom': - specifier: ^18.2.16 - version: 18.2.16 - autoprefixer: - specifier: ^10.4.16 - version: 10.4.16(postcss@8.4.31) - dotenv-cli: - specifier: ^7.3.0 - version: 7.3.0 - eslint: - specifier: ^8.54.0 - version: 8.54.0 - postcss: - specifier: ^8.4.31 - version: 8.4.31 - tailwindcss: - specifier: ^3.3.5 - version: 3.3.5 - typescript: - specifier: ^5.3.2 - version: 5.3.2 - - packages/api: - dependencies: - '@master-bot/auth': - specifier: ^0.1.0 - version: link:../auth - '@master-bot/db': - specifier: ^0.1.0 - version: link:../db - '@t3-oss/env-core': - specifier: ^0.7.1 - version: 0.7.1(typescript@5.3.2)(zod@3.22.4) - '@trpc/client': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106) - '@trpc/server': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106 - axios: - specifier: ^1.6.2 - version: 1.6.2 - discord-api-types: - specifier: ^0.37.64 - version: 0.37.64 - superjson: - specifier: 1.13.3 - version: 1.13.3 - zod: - specifier: ^3.22.4 - version: 3.22.4 - devDependencies: - '@master-bot/eslint-config': - specifier: ^0.2.0 - version: link:../config/eslint - dotenv: - specifier: ^16.3.1 - version: 16.3.1 - eslint: - specifier: ^8.54.0 - version: 8.54.0 - typescript: - specifier: ^5.3.2 - version: 5.3.2 - - packages/auth: - dependencies: - '@auth/core': - specifier: ^0.18.3 - version: 0.18.3 - '@auth/prisma-adapter': - specifier: ^1.0.8 - version: 1.0.8(@prisma/client@5.6.0) - '@master-bot/db': - specifier: ^0.1.0 - version: link:../db - '@t3-oss/env-nextjs': - specifier: ^0.7.1 - version: 0.7.1(typescript@5.3.2)(zod@3.22.4) - next: - specifier: ^14.0.3 - version: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - next-auth: - specifier: 5.0.0-beta.3 - version: 5.0.0-beta.3(next@14.0.3)(react@18.2.0) - react: - specifier: 18.2.0 - version: 18.2.0 - react-dom: - specifier: 18.2.0 - version: 18.2.0(react@18.2.0) - zod: - specifier: ^3.22.4 - version: 3.22.4 - devDependencies: - '@master-bot/eslint-config': - specifier: ^0.2.0 - version: link:../config/eslint - eslint: - specifier: ^8.54.0 - version: 8.54.0 + specifier: ^22.5.4 + version: 22.5.4 typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.5.4 + version: 5.9.3 packages/config/eslint: dependencies: '@next/eslint-plugin-next': - specifier: ^14.0.3 - version: 14.0.3 + specifier: ^15.2.0 + version: 15.2.0 '@types/eslint': - specifier: ^8.44.7 - version: 8.44.7 + specifier: ^8.56.12 + version: 8.56.12 '@typescript-eslint/eslint-plugin': - specifier: ^6.12.0 - version: 6.12.0(@typescript-eslint/parser@6.12.0)(eslint@8.54.0)(typescript@5.3.2) + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^6.12.0 - version: 6.12.0(eslint@8.54.0)(typescript@5.3.2) + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) eslint-config-prettier: - specifier: ^9.0.0 - version: 9.0.0(eslint@8.54.0) + specifier: ^9.1.2 + version: 9.1.2(eslint@8.57.1) eslint-config-turbo: - specifier: ^1.10.16 - version: 1.10.16(eslint@8.54.0) + specifier: ^1.13.4 + version: 1.13.4(eslint@8.57.1) eslint-plugin-import: - specifier: ^2.29.0 - version: 2.29.0(@typescript-eslint/parser@6.12.0)(eslint@8.54.0) + specifier: ^2.32.0 + version: 2.32.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1) eslint-plugin-jsx-a11y: - specifier: ^6.8.0 - version: 6.8.0(eslint@8.54.0) + specifier: ^6.10.2 + version: 6.10.2(eslint@8.57.1) eslint-plugin-react: - specifier: ^7.33.2 - version: 7.33.2(eslint@8.54.0) + specifier: ^7.37.5 + version: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: - specifier: ^4.6.0 - version: 4.6.0(eslint@8.54.0) + specifier: ^4.6.2 + version: 4.6.2(eslint@8.57.1) devDependencies: eslint: - specifier: ^8.54.0 - version: 8.54.0 + specifier: ^8.57.1 + version: 8.57.1 typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.5.4 + version: 5.9.3 packages/config/tailwind: devDependencies: autoprefixer: - specifier: ^10.4.16 - version: 10.4.16(postcss@8.4.31) + specifier: ^10.5.4 + version: 10.5.4(postcss@8.5.26) postcss: - specifier: ^8.4.31 - version: 8.4.31 + specifier: ^8.5.26 + version: 8.5.26 tailwindcss: - specifier: ^3.3.5 - version: 3.3.5 + specifier: ^3.4.19 + version: 3.4.19(tsx@4.19.1) packages/db: - dependencies: - '@prisma/client': - specifier: ^5.6.0 - version: 5.6.0(prisma@5.6.0) devDependencies: '@types/node': - specifier: ^20.9.3 - version: 20.9.3 - dotenv-cli: - specifier: ^7.3.0 - version: 7.3.0 - prisma: - specifier: ^5.6.0 - version: 5.6.0 + specifier: ^22.5.4 + version: 22.5.4 typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.5.4 + version: 5.9.3 packages: @@ -444,283 +231,114 @@ packages: /@alloc/quick-lru@5.2.0: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + dev: true - /@ampproject/remapping@2.2.1: - resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} - engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/gen-mapping': 0.3.3 - '@jridgewell/trace-mapping': 0.3.18 - dev: false - - /@auth/core@0.0.0-manual.e9863699: - resolution: {integrity: sha512-/hVzGuFw7nAZimliD8kpuKnNjvkRu+jpaVhYB/FaIXLNJFNwhbO2MgXBnr5tvLIHgRJnR5C9UN5RNpQXiFHuSA==} - peerDependencies: - nodemailer: ^6.8.0 - peerDependenciesMeta: - nodemailer: - optional: true - dependencies: - '@panva/hkdf': 1.1.1 - cookie: 0.5.0 - jose: 4.15.4 - oauth4webapi: 2.3.0 - preact: 10.11.3 - preact-render-to-string: 5.2.3(preact@10.11.3) - dev: false - - /@auth/core@0.18.3: - resolution: {integrity: sha512-YXQWxi3pKxngt+2vo3dq8+wDANlUH8nhQgX6EVdd3Enfe3vweBtHqzaWrtWzQnVb8wdGxdhxaoOlYroEBE+/yw==} - peerDependencies: - nodemailer: ^6.8.0 - peerDependenciesMeta: - nodemailer: - optional: true - dependencies: - '@panva/hkdf': 1.1.1 - cookie: 0.5.0 - jose: 5.1.1 - oauth4webapi: 2.3.0 - preact: 10.11.3 - preact-render-to-string: 5.2.3(preact@10.11.3) - dev: false - - /@auth/prisma-adapter@1.0.8(@prisma/client@5.6.0): - resolution: {integrity: sha512-654aQvvbWtlHKQpsxRKRm+9/V/eMdPH3LCGPqdibL8qxJtrwhvor1fo8ioJF6Xac0PDshNokH40QxoKlpk/Khg==} - peerDependencies: - '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5' - dependencies: - '@auth/core': 0.18.3 - '@prisma/client': 5.6.0(prisma@5.6.0) - transitivePeerDependencies: - - nodemailer - dev: false - - /@babel/code-frame@7.22.5: - resolution: {integrity: sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/highlight': 7.22.5 - dev: false - - /@babel/compat-data@7.22.9: - resolution: {integrity: sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==} - engines: {node: '>=6.9.0'} - dev: false - - /@babel/core@7.22.9: - resolution: {integrity: sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==} - engines: {node: '>=6.9.0'} - dependencies: - '@ampproject/remapping': 2.2.1 - '@babel/code-frame': 7.22.5 - '@babel/generator': 7.22.9 - '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) - '@babel/helper-module-transforms': 7.22.9(@babel/core@7.22.9) - '@babel/helpers': 7.22.6 - '@babel/parser': 7.22.7 - '@babel/template': 7.22.5 - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 - convert-source-map: 1.9.0 - debug: 4.3.4 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - dev: false - - /@babel/generator@7.22.9: - resolution: {integrity: sha512-KtLMbmicyuK2Ak/FTCJVbDnkN1SlT8/kceFTiuDiiRUUSMnHMidxSCdG4ndkTOHHpoomWe/4xkvHkEOncwjYIw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - '@jridgewell/gen-mapping': 0.3.3 - '@jridgewell/trace-mapping': 0.3.18 - jsesc: 2.5.2 - dev: false - - /@babel/helper-compilation-targets@7.22.9(@babel/core@7.22.9): - resolution: {integrity: sha512-7qYrNM6HjpnPHJbopxmb8hSPoZ0gsX8IvUS32JGVoy+pU9e5N0nLr1VjJoR6kA4d9dmGLxNYOjeB8sUDal2WMw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/compat-data': 7.22.9 - '@babel/core': 7.22.9 - '@babel/helper-validator-option': 7.22.5 - browserslist: 4.21.9 - lru-cache: 5.1.1 - semver: 6.3.1 - dev: false - - /@babel/helper-environment-visitor@7.22.5: - resolution: {integrity: sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==} - engines: {node: '>=6.9.0'} - dev: false - - /@babel/helper-function-name@7.22.5: - resolution: {integrity: sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.22.5 - '@babel/types': 7.22.5 - dev: false - - /@babel/helper-hoist-variables@7.22.5: - resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: false - - /@babel/helper-module-imports@7.22.5: - resolution: {integrity: sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: false - - /@babel/helper-module-transforms@7.22.9(@babel/core@7.22.9): - resolution: {integrity: sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-environment-visitor': 7.22.5 - '@babel/helper-module-imports': 7.22.5 - '@babel/helper-simple-access': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - '@babel/helper-validator-identifier': 7.22.5 - dev: false - - /@babel/helper-simple-access@7.22.5: - resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} + /@babel/code-frame@7.29.7: + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.22.5 - dev: false + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + dev: true - /@babel/helper-split-export-declaration@7.22.6: - resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} + /@babel/generator@7.29.8: + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.22.5 - dev: false - - /@babel/helper-string-parser@7.22.5: - resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==} - engines: {node: '>=6.9.0'} - dev: false - - /@babel/helper-validator-identifier@7.22.5: - resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==} - engines: {node: '>=6.9.0'} - dev: false + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + dev: true - /@babel/helper-validator-option@7.22.5: - resolution: {integrity: sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==} + /@babel/helper-globals@7.29.7: + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - dev: false + dev: true - /@babel/helpers@7.22.6: - resolution: {integrity: sha512-YjDs6y/fVOYFV8hAf1rxd1QvR9wJe1pDBZ2AREKq/SDayfPzgk0PBnVuTCE5X1acEpMMNOVUqoe+OwiZGJ+OaA==} + /@babel/helper-string-parser@7.29.7: + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.22.5 - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 - transitivePeerDependencies: - - supports-color - dev: false + dev: true - /@babel/highlight@7.22.5: - resolution: {integrity: sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==} + /@babel/helper-validator-identifier@7.29.7: + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-validator-identifier': 7.22.5 - chalk: 2.4.2 - js-tokens: 4.0.0 - dev: false + dev: true - /@babel/parser@7.22.7: - resolution: {integrity: sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==} + /@babel/parser@7.29.8: + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.22.5 - dev: false - - /@babel/runtime@7.22.6: - resolution: {integrity: sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==} - engines: {node: '>=6.9.0'} - dependencies: - regenerator-runtime: 0.13.11 - dev: false - - /@babel/runtime@7.23.4: - resolution: {integrity: sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==} - engines: {node: '>=6.9.0'} - dependencies: - regenerator-runtime: 0.14.0 - dev: false + '@babel/types': 7.29.8 + dev: true - /@babel/template@7.22.5: - resolution: {integrity: sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==} + /@babel/template@7.29.7: + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/code-frame': 7.22.5 - '@babel/parser': 7.22.7 - '@babel/types': 7.22.5 - dev: false + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + dev: true - /@babel/traverse@7.22.8: - resolution: {integrity: sha512-y6LPR+wpM2I3qJrsheCTwhIinzkETbplIgPBbwvqPKc+uljeA5gP+3nP8irdYt1mjQaDnlIcG+dw8OjAco4GXw==} + /@babel/traverse@7.29.8: + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/code-frame': 7.22.5 - '@babel/generator': 7.22.9 - '@babel/helper-environment-visitor': 7.22.5 - '@babel/helper-function-name': 7.22.5 - '@babel/helper-hoist-variables': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - '@babel/parser': 7.22.7 - '@babel/types': 7.22.5 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 debug: 4.3.4 - globals: 11.12.0 transitivePeerDependencies: - supports-color - dev: false + dev: true - /@babel/types@7.22.5: - resolution: {integrity: sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==} + /@babel/types@7.29.8: + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-string-parser': 7.22.5 - '@babel/helper-validator-identifier': 7.22.5 - to-fast-properties: 2.0.0 - dev: false + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + dev: true - /@colors/colors@1.5.0: - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - dev: false + /@bcoe/v8-coverage@1.0.2: + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + dev: true /@colors/colors@1.6.0: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} dev: false - /@dabh/diagnostics@2.0.3: - resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + /@dabh/diagnostics@2.0.8: + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} dependencies: - colorspace: 1.1.4 + '@so-ric/colorspace': 1.1.6 enabled: 2.0.0 kuler: 2.0.0 dev: false + /@discordjs/builders@1.14.1: + resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==} + engines: {node: '>=16.11.0'} + dependencies: + '@discordjs/formatters': 0.6.2 + '@discordjs/util': 1.2.0 + '@sapphire/shapeshift': 4.0.0 + discord-api-types: 0.38.54 + fast-deep-equal: 3.1.3 + ts-mixer: 6.0.4 + tslib: 2.8.1 + dev: false + /@discordjs/builders@1.7.0: resolution: {integrity: sha512-GDtbKMkg433cOZur8Dv6c25EHxduNIBsxeHrsRoIM8+AwmEZ8r0tEpckx/sHwTLwQPOF3e2JWloZh9ofCaMfAw==} engines: {node: '>=16.11.0'} @@ -731,7 +349,7 @@ packages: discord-api-types: 0.37.61 fast-deep-equal: 3.1.3 ts-mixer: 6.0.3 - tslib: 2.6.2 + tslib: 2.8.1 dev: false /@discordjs/collection@1.5.3: @@ -739,8 +357,8 @@ packages: engines: {node: '>=16.11.0'} dev: false - /@discordjs/collection@2.0.0: - resolution: {integrity: sha512-YTWIXLrf5FsrLMycpMM9Q6vnZoR/lN2AWX23/Cuo8uOOtS8eHB2dyQaaGnaF8aZPYnttf2bkLMcXn/j6JUOi3w==} + /@discordjs/collection@2.1.1: + resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} engines: {node: '>=18'} dev: false @@ -751,19 +369,26 @@ packages: discord-api-types: 0.37.61 dev: false - /@discordjs/rest@2.2.0: - resolution: {integrity: sha512-nXm9wT8oqrYFRMEqTXQx9DUTeEtXUDMmnUKIhZn6O2EeDY9VCdwj23XCPq7fkqMPKdF7ldAfeVKyxxFdbZl59A==} + /@discordjs/formatters@0.6.2: + resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==} engines: {node: '>=16.11.0'} dependencies: - '@discordjs/collection': 2.0.0 - '@discordjs/util': 1.0.2 - '@sapphire/async-queue': 1.5.0 - '@sapphire/snowflake': 3.5.1 - '@vladfrangu/async_event_emitter': 2.2.2 - discord-api-types: 0.37.61 - magic-bytes.js: 1.5.0 - tslib: 2.6.2 - undici: 5.27.2 + discord-api-types: 0.38.54 + dev: false + + /@discordjs/rest@2.6.3: + resolution: {integrity: sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==} + engines: {node: '>=18'} + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@sapphire/snowflake': 3.5.5 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.54 + magic-bytes.js: 1.13.1 + tslib: 2.8.1 + undici: 6.28.0 dev: false /@discordjs/util@1.0.2: @@ -771,96 +396,287 @@ packages: engines: {node: '>=16.11.0'} dev: false - /@discordjs/ws@1.0.2: - resolution: {integrity: sha512-+XI82Rm2hKnFwAySXEep4A7Kfoowt6weO6381jgW+wVdTpMS/56qCvoXyFRY0slcv7c/U8My2PwIB2/wEaAh7Q==} + /@discordjs/util@1.2.0: + resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==} + engines: {node: '>=18'} + dependencies: + discord-api-types: 0.38.54 + dev: false + + /@discordjs/ws@1.2.3: + resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} engines: {node: '>=16.11.0'} dependencies: - '@discordjs/collection': 2.0.0 - '@discordjs/rest': 2.2.0 - '@discordjs/util': 1.0.2 - '@sapphire/async-queue': 1.5.0 - '@types/ws': 8.5.9 - '@vladfrangu/async_event_emitter': 2.2.2 - discord-api-types: 0.37.61 - tslib: 2.6.2 - ws: 8.14.2 + '@discordjs/collection': 2.1.1 + '@discordjs/rest': 2.6.3 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@types/ws': 8.18.1 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.54 + tslib: 2.8.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate dev: false - /@eslint-community/eslint-utils@4.4.0(eslint@8.54.0): - resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - dependencies: - eslint: 8.54.0 - eslint-visitor-keys: 3.4.2 - - /@eslint-community/regexpp@4.6.2: - resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + /@esbuild/aix-ppc64@0.23.1: + resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true - /@eslint/eslintrc@2.1.3: - resolution: {integrity: sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dependencies: - ajv: 6.12.6 - debug: 4.3.4 - espree: 9.6.1 - globals: 13.20.0 - ignore: 5.2.4 - import-fresh: 3.3.0 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color + /@esbuild/android-arm64@0.23.1: + resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true - /@eslint/js@8.54.0: - resolution: {integrity: sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + /@esbuild/android-arm@0.23.1: + resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true - /@fastify/busboy@2.1.0: - resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==} - engines: {node: '>=14'} - dev: false + /@esbuild/android-x64@0.23.1: + resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true - /@floating-ui/core@1.4.0: - resolution: {integrity: sha512-x5Ly1Eiyqt9aR38XzhraoWxgtQtvy3mVChWMZIr49XFyvIhNuqUxZKXBRoI5WiMRaaAZezCauJaEISu3z5y8sg==} - dependencies: - '@floating-ui/utils': 0.1.0 - dev: false + /@esbuild/darwin-arm64@0.23.1: + resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true - /@floating-ui/dom@1.5.0: - resolution: {integrity: sha512-9jPin5dTlcEN+nXzBRhdreCzlJBIYWeMXpJJ5VnO1l9dLcP7uQNPbmwmIoHpHpH6GPYMYtQA7GfkvsSj/CQPwg==} - dependencies: - '@floating-ui/core': 1.4.0 - '@floating-ui/utils': 0.1.0 - dev: false + /@esbuild/darwin-x64@0.23.1: + resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true - /@floating-ui/react-dom@2.0.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-rZtAmSht4Lry6gdhAJDrCp/6rKN7++JnL1/Anbr/DdeyYXQPxvg/ivrbYvJulbRf4vL8b212suwMM2lxbv+RQA==} - peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' - dependencies: - '@floating-ui/dom': 1.5.0 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + /@esbuild/freebsd-arm64@0.23.1: + resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true - /@floating-ui/utils@0.1.0: - resolution: {integrity: sha512-ZSlli/beGZdvoqT3/Y9oOW79XSEpBfxt8UY6vjyWJW0B8d/M+MKlkQ3kBzLKDXaSsB84IVj6QntQfHLzesB4mA==} - dev: false + /@esbuild/freebsd-x64@0.23.1: + resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true - /@humanwhocodes/config-array@0.11.13: - resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} - engines: {node: '>=10.10.0'} + /@esbuild/linux-arm64@0.23.1: + resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.23.1: + resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.23.1: + resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.23.1: + resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.23.1: + resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.23.1: + resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.23.1: + resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.23.1: + resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.23.1: + resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.23.1: + resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-arm64@0.23.1: + resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.23.1: + resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.23.1: + resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.23.1: + resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.23.1: + resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.23.1: + resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@eslint-community/eslint-utils@4.4.0(eslint@8.57.1): + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 dependencies: - '@humanwhocodes/object-schema': 2.0.1 - debug: 4.3.4 + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + /@eslint-community/regexpp@4.6.2: + resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + /@eslint/eslintrc@2.1.4: + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + ajv: 6.12.6 + debug: 4.4.3 + espree: 9.6.1 + globals: 13.20.0 + ignore: 5.2.4 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + /@eslint/js@8.57.1: + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + /@humanwhocodes/config-array@0.13.0: + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.3 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -869,70 +685,60 @@ packages: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - /@humanwhocodes/object-schema@2.0.1: - resolution: {integrity: sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==} + /@humanwhocodes/object-schema@2.0.3: + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead - /@ianvs/prettier-plugin-sort-imports@4.1.1(prettier@3.1.0): - resolution: {integrity: sha512-kJhXq63ngpTQ2dxgf5GasbPJWsJA3LgoOdd7WGhpUSzLgLgI4IsIzYkbJf9kmpOHe7Vdm/o3PcRA3jmizXUuAQ==} + /@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.9.6): + resolution: {integrity: sha512-jmTNYGlg95tlsoG3JLCcuC4BrFELJtLirLAkQW/71lXSyOhVt/Xj7xWbbGcuVbNq1gwWgSyMrPjJc9Z30hynVw==} peerDependencies: - '@vue/compiler-sfc': '>=3.0.0' - prettier: 2 || 3 + '@prettier/plugin-oxc': ^0.0.4 || ^0.1.0 + '@vue/compiler-sfc': 2.7.x || 3.x + content-tag: ^4.0.0 + prettier: 2 || 3 || ^4.0.0-0 + prettier-plugin-ember-template-tag: ^2.1.0 peerDependenciesMeta: + '@prettier/plugin-oxc': + optional: true '@vue/compiler-sfc': optional: true + content-tag: + optional: true + prettier-plugin-ember-template-tag: + optional: true dependencies: - '@babel/core': 7.22.9 - '@babel/generator': 7.22.9 - '@babel/parser': 7.22.7 - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 - prettier: 3.1.0 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + prettier: 3.9.6 semver: 7.5.4 transitivePeerDependencies: - supports-color - dev: false - - /@ioredis/commands@1.2.0: - resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} - dev: false + dev: true - /@jridgewell/gen-mapping@0.3.3: - resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} - engines: {node: '>=6.0.0'} + /@jridgewell/gen-mapping@0.3.13: + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} dependencies: - '@jridgewell/set-array': 1.1.2 - '@jridgewell/sourcemap-codec': 1.4.15 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + dev: true /@jridgewell/resolve-uri@3.1.0: resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} engines: {node: '>=6.0.0'} + dev: true - /@jridgewell/set-array@1.1.2: - resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} - engines: {node: '>=6.0.0'} - - /@jridgewell/sourcemap-codec@1.4.14: - resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} - - /@jridgewell/sourcemap-codec@1.4.15: - resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + /@jridgewell/sourcemap-codec@1.6.0: + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + dev: true - /@jridgewell/trace-mapping@0.3.18: - resolution: {integrity: sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==} + /@jridgewell/trace-mapping@0.3.31: + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} dependencies: '@jridgewell/resolve-uri': 3.1.0 - '@jridgewell/sourcemap-codec': 1.4.14 - - /@lavaclient/spotify@3.1.0: - resolution: {integrity: sha512-B9AwZVyxScjJnJWJa4zMylF2i2/UOvDKL7lHWMxcezBMvOqjM+rZMr4ZTJj179qdpOaQ7zc8mBfRYSXGWJIWmA==} - engines: {node: '>=16'} - dependencies: - tslib: 2.6.2 - dev: false - - /@lavaclient/types@2.1.1: - resolution: {integrity: sha512-r69sXGyUQgqsNiDHYRm2uWuDumRydylIB0k51lKkVzFinI+DcBq2hKW3KJkImT4+YY5boQ6K7HPAO9RvhJkQDg==} + '@jridgewell/sourcemap-codec': 1.6.0 + dev: true /@lavalink/encoding@0.1.2: resolution: {integrity: sha512-cUhsKYBw91+2H4wPfKCi1i9dyQmXGCcNvsNe3Sgy67ZrjsQ29hNLLEBIotBIxPMPWTFZtQcja5QFL99UNvjv0w==} @@ -940,55 +746,50 @@ packages: base64-js: 1.5.1 dev: false - /@manypkg/cli@0.21.0: - resolution: {integrity: sha512-q/JF25il2EXtyPpc5U/Pp7TMgJot/WmFkyh7M9FiutQkliHp58UqUxIPeUObLu9EtoAp/uP21t+TMDsq1DMbeg==} - engines: {node: '>=14.18.0'} + /@manypkg/cli@0.25.1: + resolution: {integrity: sha512-lag906FyiNxzZjsRErkUD5/to174I2JzPk5bZubuJp6loMKKJn73zrtqeU7nHlVkHBg3tgXDTJj22HxUDxLRXw==} + engines: {node: '>=20.0.0'} hasBin: true dependencies: - '@manypkg/get-packages': 2.2.0 - chalk: 2.4.2 - detect-indent: 6.1.0 - find-up: 4.1.0 - fs-extra: 8.1.0 + '@manypkg/get-packages': 3.1.0 + detect-indent: 7.0.2 normalize-path: 3.0.0 - p-limit: 2.3.0 - package-json: 6.5.0 - parse-github-url: 1.0.2 - sembear: 0.5.2 - semver: 6.3.1 - spawndamnit: 2.0.0 - validate-npm-package-name: 3.0.0 - dev: false + p-limit: 6.2.0 + package-json: 10.0.1 + parse-github-url: 1.0.4 + picocolors: 1.1.1 + sembear: 0.7.0 + semver: 7.8.5 + tinyexec: 1.3.0 + validate-npm-package-name: 6.0.2 + dev: true - /@manypkg/find-root@2.2.1: - resolution: {integrity: sha512-34NlypD5mmTY65cFAK7QPgY5Tzt0qXR4ZRXdg97xAlkiLuwXUPBEXy5Hsqzd+7S2acsLxUz6Cs50rlDZQr4xUA==} - engines: {node: '>=14.18.0'} + /@manypkg/find-root@3.1.0: + resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} + engines: {node: '>=20.0.0'} dependencies: - '@manypkg/tools': 1.1.0 - find-up: 4.1.0 - fs-extra: 8.1.0 - dev: false + '@manypkg/tools': 2.1.2 + dev: true - /@manypkg/get-packages@2.2.0: - resolution: {integrity: sha512-B5p5BXMwhGZKi/syEEAP1eVg5DZ/9LP+MZr0HqfrHLgu9fq0w4ZwH8yVen4JmjrxI2dWS31dcoswYzuphLaRxg==} - engines: {node: '>=14.18.0'} + /@manypkg/get-packages@3.1.0: + resolution: {integrity: sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg==} + engines: {node: '>=20.0.0'} dependencies: - '@manypkg/find-root': 2.2.1 - '@manypkg/tools': 1.1.0 - dev: false + '@manypkg/find-root': 3.1.0 + '@manypkg/tools': 2.1.2 + dev: true - /@manypkg/tools@1.1.0: - resolution: {integrity: sha512-SkAyKAByB9l93Slyg8AUHGuM2kjvWioUTCckT/03J09jYnfEzMO/wSXmEhnKGYs6qx9De8TH4yJCl0Y9lRgnyQ==} - engines: {node: '>=14.18.0'} + /@manypkg/tools@2.1.2: + resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==} + engines: {node: '>=20.0.0'} dependencies: - fs-extra: 8.1.0 - globby: 11.1.0 jju: 1.4.0 - read-yaml-file: 1.1.0 - dev: false + tinyglobby: 0.2.17 + yaml: 2.9.0 + dev: true - /@napi-rs/canvas-android-arm64@0.1.44: - resolution: {integrity: sha512-3UDlVf1CnibdUcM0+0xPH4L4/d/tCI895or0y7mr/Xlaa1tDmvcQCvBYl9G54IpXsm+e4T1XkVrGGJD4k1NfSg==} + /@napi-rs/canvas-android-arm64@1.0.8: + resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==} engines: {node: '>= 10'} cpu: [arm64] os: [android] @@ -996,8 +797,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-darwin-arm64@0.1.44: - resolution: {integrity: sha512-Y1Yx0H45Iicx2b6pcrlICjlwgylLtqi0t5OJgeUXnxLcJ1+aEpmjLr16tddqHkmGUw/nBRAwfPJrf3GaOwWowQ==} + /@napi-rs/canvas-darwin-arm64@1.0.8: + resolution: {integrity: sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -1005,8 +806,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-darwin-x64@0.1.44: - resolution: {integrity: sha512-gbzeNz13DFH0Ak5ENyQ5ZEuSuCjNDxA/OV9P5f19lywbOVL5Ol+qgKX0BXBcP3O3IXWahruOvmmLUBn9h1MHpA==} + /@napi-rs/canvas-darwin-x64@1.0.8: + resolution: {integrity: sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -1014,8 +815,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-linux-arm-gnueabihf@0.1.44: - resolution: {integrity: sha512-Sad3/eGyzTZiyJFeFrmX1M3aRp0n3qTAXeCm6EeAjCFGk8TWd4cINCGT3IRY4wmCvNnpe6C4fM03K07cU5YYwA==} + /@napi-rs/canvas-linux-arm-gnueabihf@1.0.8: + resolution: {integrity: sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] @@ -1023,17 +824,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-linux-arm64-gnu@0.1.44: - resolution: {integrity: sha512-bCrI9naYGPRFHePMGN+wlrWzC+Swi6uc1YzFg4/wOYzHKSte8FXHrGspHOPPr12BCEmgg3yXK8nnLjxGdlAWtg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@napi-rs/canvas-linux-arm64-musl@0.1.44: - resolution: {integrity: sha512-gB/ao9zBQaOJik4arOKJisZaG+v7DuyBW7UdG+0L80msAuJTTH2UgWOnmXfZwPxzxNbFKzOa8r48uVzfTaAHGQ==} + /@napi-rs/canvas-linux-arm64-gnu@1.0.8: + resolution: {integrity: sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1041,78 +833,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-linux-x64-gnu@0.1.44: - resolution: {integrity: sha512-pvHy1bJ0DDD4Bsx6yuFnqpIyBW7+2iIK5BpvmL36zXE+7w2MEeaYzLUWTBhrXj8rzHys6MwLmHNlkw65R80YbQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@napi-rs/canvas-linux-x64-musl@0.1.44: - resolution: {integrity: sha512-5QaeYqNZ/u1QI2E/UqvnmuORT6cI1qTtLosPp/y4awaK+/LXQEzotHNv0nan0z4EV/0mXsJswY9JpISRJzx+Kw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - requiresBuild: true - dev: false - optional: true - - /@napi-rs/canvas-win32-x64-msvc@0.1.44: - resolution: {integrity: sha512-pbeTGLox+I+sMVl/FFO21Xvp0PhijsuEr9gaynmN2X7FPTg+CCuuBDhfSU5iMAtcCCYFCk8ridZIWy5jkcf72w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /@napi-rs/canvas@0.1.44: - resolution: {integrity: sha512-IyhSndjw29LR1WqkUZvTJI4j8Ve1QGbZYtpdQjJjcFvsvJS4/WHzOWV8ZciLPJBhrYvSQf/JbZJy5LHmFV+plg==} - engines: {node: '>= 10'} - optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.44 - '@napi-rs/canvas-darwin-arm64': 0.1.44 - '@napi-rs/canvas-darwin-x64': 0.1.44 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.44 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.44 - '@napi-rs/canvas-linux-arm64-musl': 0.1.44 - '@napi-rs/canvas-linux-x64-gnu': 0.1.44 - '@napi-rs/canvas-linux-x64-musl': 0.1.44 - '@napi-rs/canvas-win32-x64-msvc': 0.1.44 - dev: false - - /@next/env@14.0.3: - resolution: {integrity: sha512-7xRqh9nMvP5xrW4/+L0jgRRX+HoNRGnfJpD+5Wq6/13j3dsdzxO3BCXn7D3hMqsDb+vjZnJq+vI7+EtgrYZTeA==} - dev: false - - /@next/eslint-plugin-next@14.0.3: - resolution: {integrity: sha512-j4K0n+DcmQYCVnSAM+UByTVfIHnYQy2ODozfQP+4RdwtRDfobrIvKq1K4Exb2koJ79HSSa7s6B2SA8T/1YR3RA==} - dependencies: - glob: 7.1.7 - dev: false - - /@next/swc-darwin-arm64@14.0.3: - resolution: {integrity: sha512-64JbSvi3nbbcEtyitNn2LEDS/hcleAFpHdykpcnrstITFlzFgB/bW0ER5/SJJwUPj+ZPY+z3e+1jAfcczRLVGw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - requiresBuild: true - dev: false - optional: true - - /@next/swc-darwin-x64@14.0.3: - resolution: {integrity: sha512-RkTf+KbAD0SgYdVn1XzqE/+sIxYGB7NLMZRn9I4Z24afrhUpVJx6L8hsRnIwxz3ERE2NFURNliPjJ2QNfnWicQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - requiresBuild: true - dev: false - optional: true - - /@next/swc-linux-arm64-gnu@14.0.3: - resolution: {integrity: sha512-3tBWGgz7M9RKLO6sPWC6c4pAw4geujSwQ7q7Si4d6bo0l6cLs4tmO+lnSwFp1Tm3lxwfMk0SgkJT7EdwYSJvcg==} + /@napi-rs/canvas-linux-arm64-musl@1.0.8: + resolution: {integrity: sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1120,17 +842,17 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-musl@14.0.3: - resolution: {integrity: sha512-v0v8Kb8j8T23jvVUWZeA2D8+izWspeyeDGNaT2/mTHWp7+37fiNfL8bmBWiOmeumXkacM/AB0XOUQvEbncSnHA==} + /@napi-rs/canvas-linux-riscv64-gnu@1.0.8: + resolution: {integrity: sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==} engines: {node: '>= 10'} - cpu: [arm64] + cpu: [riscv64] os: [linux] requiresBuild: true dev: false optional: true - /@next/swc-linux-x64-gnu@14.0.3: - resolution: {integrity: sha512-VM1aE1tJKLBwMGtyBR21yy+STfl0MapMQnNrXkxeyLs0GFv/kZqXS5Jw/TQ3TSUnbv0QPDf/X8sDXuMtSgG6eg==} + /@napi-rs/canvas-linux-x64-gnu@1.0.8: + resolution: {integrity: sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1138,8 +860,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-musl@14.0.3: - resolution: {integrity: sha512-64EnmKy18MYFL5CzLaSuUn561hbO1Gk16jM/KHznYP3iCIfF9e3yULtHaMy0D8zbHfxset9LTOv6cuYKJgcOxg==} + /@napi-rs/canvas-linux-x64-musl@1.0.8: + resolution: {integrity: sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1147,8 +869,8 @@ packages: dev: false optional: true - /@next/swc-win32-arm64-msvc@14.0.3: - resolution: {integrity: sha512-WRDp8QrmsL1bbGtsh5GqQ/KWulmrnMBgbnb+59qNTW1kVi1nG/2ndZLkcbs2GX7NpFLlToLRMWSQXmPzQm4tog==} + /@napi-rs/canvas-win32-arm64-msvc@1.0.8: + resolution: {integrity: sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -1156,23 +878,37 @@ packages: dev: false optional: true - /@next/swc-win32-ia32-msvc@14.0.3: - resolution: {integrity: sha512-EKffQeqCrj+t6qFFhIFTRoqb2QwX1mU7iTOvMyLbYw3QtqTw9sMwjykyiMlZlrfm2a4fA84+/aeW+PMg1MjuTg==} + /@napi-rs/canvas-win32-x64-msvc@1.0.8: + resolution: {integrity: sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==} engines: {node: '>= 10'} - cpu: [ia32] + cpu: [x64] os: [win32] requiresBuild: true dev: false optional: true - /@next/swc-win32-x64-msvc@14.0.3: - resolution: {integrity: sha512-ERhKPSJ1vQrPiwrs15Pjz/rvDHZmkmvbf/BjPN/UCOI++ODftT0GtasDPi0j+y6PPJi5HsXw+dpRaXUaw4vjuQ==} + /@napi-rs/canvas@1.0.8: + resolution: {integrity: sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==} engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - requiresBuild: true + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.8 + '@napi-rs/canvas-darwin-arm64': 1.0.8 + '@napi-rs/canvas-darwin-x64': 1.0.8 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.8 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.8 + '@napi-rs/canvas-linux-arm64-musl': 1.0.8 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.8 + '@napi-rs/canvas-linux-x64-gnu': 1.0.8 + '@napi-rs/canvas-linux-x64-musl': 1.0.8 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.8 + '@napi-rs/canvas-win32-x64-msvc': 1.0.8 + dev: false + + /@next/eslint-plugin-next@15.2.0: + resolution: {integrity: sha512-jHFUG2OwmAuOASqq253RAEG/5BYcPHn27p1NoWZDCf4OdvdK0yRYWX92YKkL+Mk2s+GyJrmd/GATlL5b2IySpw==} + dependencies: + fast-glob: 3.3.1 dev: false - optional: true /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1192,678 +928,219 @@ packages: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - /@panva/hkdf@1.1.1: - resolution: {integrity: sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==} - dev: false + /@oxc-project/types@0.148.0: + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + dev: true - /@prisma/client@5.6.0(prisma@5.6.0): - resolution: {integrity: sha512-mUDefQFa1wWqk4+JhKPYq8BdVoFk9NFMBXUI8jAkBfQTtgx8WPx02U2HB/XbAz3GSUJpeJOKJQtNvaAIDs6sug==} - engines: {node: '>=16.13'} - requiresBuild: true - peerDependencies: - prisma: '*' - peerDependenciesMeta: - prisma: - optional: true + /@pnpm/config.env-replace@1.1.0: + resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} + engines: {node: '>=12.22.0'} + dev: true + + /@pnpm/network.ca-file@1.0.2: + resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} + engines: {node: '>=12.22.0'} dependencies: - '@prisma/engines-version': 5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee - prisma: 5.6.0 - dev: false + graceful-fs: 4.2.10 + dev: true - /@prisma/engines-version@5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee: - resolution: {integrity: sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw==} - dev: false + /@pnpm/npm-conf@3.0.3: + resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} + engines: {node: '>=12'} + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + dev: true - /@prisma/engines@5.6.0: - resolution: {integrity: sha512-Mt2q+GNJpU2vFn6kif24oRSBQv1KOkYaterQsi0k2/lA+dLvhRX6Lm26gon6PYHwUM8/h8KRgXIUMU0PCLB6bw==} + /@rolldown/binding-android-arm-eabi@1.2.7: + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] requiresBuild: true + dev: true + optional: true - /@radix-ui/number@1.0.1: - resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==} - dependencies: - '@babel/runtime': 7.22.6 - dev: false + /@rolldown/binding-android-arm64@1.2.7: + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true - /@radix-ui/primitive@1.0.1: - resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} - dependencies: - '@babel/runtime': 7.22.6 - dev: false + /@rolldown/binding-darwin-arm64@1.2.7: + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-slot': 1.0.2(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false - - /@radix-ui/react-context@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false - - /@radix-ui/react-direction@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false - - /@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-dropdown-menu@2.0.6(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-id': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-menu': 2.0.6(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-focus-guards@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false - - /@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-id@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rolldown/binding-darwin-x64@1.2.7: + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-menu@2.0.6(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-direction': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-id': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-slot': 1.0.2(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - aria-hidden: 1.2.3 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.5.5(@types/react@18.2.38)(react@18.2.0) - dev: false - - /@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@floating-ui/react-dom': 2.0.1(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-rect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/rect': 1.0.1 - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-portal@1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + /@rolldown/binding-freebsd-x64@1.2.7: + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-presence@1.0.1(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + /@rolldown/binding-linux-arm-gnueabihf@1.2.7: + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-slot': 1.0.2(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + /@rolldown/binding-linux-arm64-gnu@1.2.7: + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-direction': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-id': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-select@2.0.0(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/number': 1.0.1 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-direction': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-id': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-slot': 1.0.2(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - aria-hidden: 1.2.3 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.5.5(@types/react@18.2.38)(react@18.2.0) - dev: false - - /@radix-ui/react-slot@1.0.2(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rolldown/binding-linux-arm64-musl@1.2.7: + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-toast@1.1.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rolldown/binding-linux-ppc64-gnu@1.2.7: + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rolldown/binding-linux-s390x-gnu@1.2.7: + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rolldown/binding-linux-x64-gnu@1.2.7: + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rolldown/binding-linux-x64-musl@1.2.7: + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rolldown/binding-openharmony-arm64@1.2.7: + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-rect@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/rect': 1.0.1 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rolldown/binding-win32-arm64-msvc@1.2.7: + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-size@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rolldown/binding-win32-x64-msvc@1.2.7: + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + /@rolldown/pluginutils@1.0.1: + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + dev: true - /@radix-ui/rect@1.0.1: - resolution: {integrity: sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==} - dependencies: - '@babel/runtime': 7.22.6 + /@rtsao/scc@1.1.0: + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} dev: false - /@sapphire/async-queue@1.5.0: - resolution: {integrity: sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==} + /@sapphire/async-queue@1.5.5: + resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false - /@sapphire/cron@1.1.1: - resolution: {integrity: sha512-SBQepfBkwCzYBqMfYB+lrfx7AK6zVdT4lK7X4Q0SthxYS82MYw6qAiRUd24bzhaXc33KBk7g7Uljbxu98qDDJw==} + /@sapphire/cron@1.2.1: + resolution: {integrity: sha512-K96GX4UkzgC/Y2VHXVjhM2Bl4D04552nr/fDiOj9bOACW1+wqeFfLJ3eV6jleTSXmzPIwDvkPIUJLf8A5KSD+w==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: - '@sapphire/utilities': 3.13.0 + '@sapphire/utilities': 3.18.2 dev: false - /@sapphire/decorators@6.0.2: - resolution: {integrity: sha512-R0bsVvvT/iclElvdglpneIB6UGhzqT3DbMy8b0VHcjSSWArAfxFXiv7mVO/5VeiQduZFhWPgqtTWayKdRYY1NA==} + /@sapphire/decorators@6.2.0: + resolution: {integrity: sha512-st1DNDCNoZaZYz3fgCA99W87Bhe6XqM8y0G+Z9NBOBEqvOYkVNGcPMDrMiaZrPD9Z471k8fpLqJVUNSuWUx8Hg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: - tslib: 2.6.2 + tslib: 2.8.1 dev: false /@sapphire/discord-utilities@3.2.0: resolution: {integrity: sha512-gKgTkWIBgkG0c+V3ALXeoD7XeciAYQtHNewjltSMaxCLF/wsy6NFj6xxqdEeSJMF40tcfUJ7F44r2DR5r3K8Eg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: - discord-api-types: 0.37.64 + discord-api-types: 0.37.120 + dev: false + + /@sapphire/discord-utilities@3.5.0: + resolution: {integrity: sha512-H4SY5KTVDZrqA5QG7ob6etwqhdOb3TRSY2wv56f0tiobUdIr0irlrYvdmr8Kg/FRxWU+aiHDIISWGG5vBuxOGw==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + dependencies: + discord-api-types: 0.38.54 dev: false - /@sapphire/discord.js-utilities@7.1.2: - resolution: {integrity: sha512-Ly/mtykmX7lak4+fzVbDvch0xnlAwDIDGZGg9mGuZVHdA4sINWcVoCNulI+OqoZWdLCfUKph32KvXGESzriD7A==} + /@sapphire/discord.js-utilities@7.3.3: + resolution: {integrity: sha512-WDj+zjWgNCUSvzYDD0wY3TVeTUseHq0Nhk0wVWxSDjY8z2gFEVcpY7wF8/fbTDWP44LUG5sUQ4haIrIj2OjmkQ==} engines: {node: '>=16.6.0', npm: '>=7.0.0'} dependencies: - '@sapphire/discord-utilities': 3.2.0 - '@sapphire/duration': 1.1.0 - '@sapphire/utilities': 3.13.0 - tslib: 2.6.2 + '@sapphire/discord-utilities': 3.5.0 + '@sapphire/duration': 1.2.0 + '@sapphire/utilities': 3.18.2 + tslib: 2.8.1 dev: false - /@sapphire/duration@1.1.0: - resolution: {integrity: sha512-ATb2pWPLcSgG7bzvT6MglUcDexFSufr2FLXUmhipWGFtZbvDhkopGBIuHyzoGy7LZvL8UY5T6pRLNdFv5pl/Lg==} + /@sapphire/duration@1.2.0: + resolution: {integrity: sha512-LxjOAFXz81WmrI8XX9YaVcAZDjQj/1p78lZCvkAWZB1nphOwz/D0dU3CBejmhOWx5dO5CszTkLJMNR0xuCK+Zg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false @@ -1873,13 +1150,13 @@ packages: dependencies: '@discordjs/builders': 1.7.0 '@sapphire/discord-utilities': 3.2.0 - '@sapphire/discord.js-utilities': 7.1.2 + '@sapphire/discord.js-utilities': 7.3.3 '@sapphire/lexure': 1.1.5 '@sapphire/pieces': 3.10.0 '@sapphire/ratelimits': 2.4.7 '@sapphire/result': 2.6.4 '@sapphire/stopwatch': 1.5.0 - '@sapphire/utilities': 3.13.0 + '@sapphire/utilities': 3.18.2 dev: false /@sapphire/lexure@1.1.5: @@ -1894,8 +1171,8 @@ packages: engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: '@discordjs/collection': 1.5.3 - '@sapphire/utilities': 3.13.0 - tslib: 2.6.2 + '@sapphire/utilities': 3.18.2 + tslib: 2.8.1 dev: false /@sapphire/plugin-hmr@2.0.3: @@ -1923,8 +1200,16 @@ packages: lodash: 4.17.21 dev: false - /@sapphire/snowflake@3.5.1: - resolution: {integrity: sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==} + /@sapphire/shapeshift@4.0.0: + resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} + engines: {node: '>=v16'} + dependencies: + fast-deep-equal: 3.1.3 + lodash: 4.17.21 + dev: false + + /@sapphire/snowflake@3.5.5: + resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false @@ -1932,252 +1217,91 @@ packages: resolution: {integrity: sha512-DtyKugdy3JTqm6JnEepTY64fGJAqlusDVrlrzifEgSCfGYCqpvB+SBldkWtDH+z+zLcp+PyaFLq7xpVfkhmvGg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: - tslib: 2.6.2 + tslib: 2.8.1 dev: false - /@sapphire/time-utilities@1.7.10: - resolution: {integrity: sha512-icmuse7m3oGJXRtweTmTT6vMMtCpWwGCpzephI5K8aQQRsfZwKYA+jAriSnT4+Lfw6LcR8j7TfkAAX7SyOOggQ==} + /@sapphire/time-utilities@1.7.14: + resolution: {integrity: sha512-UVJQ9oyzXcmJVVf9Y9ucGzRmKoPxe6SehpQlBNTX7CTVu0aj1lrEGL+bBa0Mu7hxF5DYXYjDYeO1e5BR0eg90Q==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: - '@sapphire/cron': 1.1.1 - '@sapphire/duration': 1.1.0 - '@sapphire/timer-manager': 1.0.0 - '@sapphire/timestamp': 1.0.1 + '@sapphire/cron': 1.2.1 + '@sapphire/duration': 1.2.0 + '@sapphire/timer-manager': 1.0.4 + '@sapphire/timestamp': 1.0.5 dev: false - /@sapphire/timer-manager@1.0.0: - resolution: {integrity: sha512-vxxnv75QPMGKt6IB6nL2xRJfwzcUQ9DBGzJLg6G8eS5O4u7j3IR/yr/GQsa4gIpjw6kQOgn8lUdnSTlpnERTbQ==} + /@sapphire/timer-manager@1.0.4: + resolution: {integrity: sha512-gc1JW8oui86f2l0T/1Iwd7hZTgus8b46slitTR2y0+wiMAEYxdp6vVTHvMAEHOqX8drkxK70aGfZmQKbAcgFqQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false - /@sapphire/timestamp@1.0.1: - resolution: {integrity: sha512-uLg+rBFuBiaQY/pFGDDzZSOH2cfv4ONIB7zQGNuRCTpYKBW/iIhRBIZjJZyn8NVkXQhVi+Q94DI4i6gDhYVs7w==} + /@sapphire/timestamp@1.0.5: + resolution: {integrity: sha512-oNwWyNdbt5wm4aYZvlHl1+64U3g0xrFmRIHsnER7RgMxNnp/wmAE4yTK2oUHeadg3t4V9iYctPAQCF+aINke4g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false - /@sapphire/ts-config@5.0.0: - resolution: {integrity: sha512-E4JfpCK/OxaH8lYEP0+xbRUeIBanEOkA5IUtvj+Uib2TG2p7H0Sb1mF1fpmf0ICyDaOu9/201Il/ymV3cr/isw==} + /@sapphire/ts-config@5.0.3: + resolution: {integrity: sha512-bFyGYHFT3TpOf5Sg2P+zY2ad0t5IA2epc5HtewlghhL7MYvbZvxtKsdaNaMwAdNObBx7hpiQm5OcOhyzEwQvbQ==} engines: {node: '>=v16.0.0', npm: '>=8.0.0'} dependencies: - tslib: 2.6.2 - typescript: 5.3.2 + tslib: 2.8.1 + typescript: 5.4.5 dev: true - /@sapphire/utilities@3.13.0: - resolution: {integrity: sha512-BD5ycPjZX5dXxrAb90dJTY8ukpPVBXgU17gA5ghK2memS4hwAzFYpvK+R+6zh4d6HYIKVuqrVhGXjvZenAa/Aw==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} - dev: false - - /@sindresorhus/is@0.14.0: - resolution: {integrity: sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==} - engines: {node: '>=6'} - dev: false - - /@swc/helpers@0.5.2: - resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} - dependencies: - tslib: 2.6.2 - dev: false - - /@szmarczak/http-timer@1.1.2: - resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==} - engines: {node: '>=6'} - dependencies: - defer-to-connect: 1.1.3 - dev: false - - /@t3-oss/env-core@0.7.1(typescript@5.3.2)(zod@3.22.4): - resolution: {integrity: sha512-3+SQt39OlmSaRLqYVFv8uRm1BpFepM5TIiMytRqO9cjH+wB77o6BIJdeyM5h5U4qLBMEzOJWCY4MBaU/rLwbYw==} - peerDependencies: - typescript: '>=4.7.2' - zod: ^3.0.0 - peerDependenciesMeta: - typescript: - optional: true - dependencies: - typescript: 5.3.2 - zod: 3.22.4 - dev: false - - /@t3-oss/env-nextjs@0.7.1(typescript@5.3.2)(zod@3.22.4): - resolution: {integrity: sha512-tQDbNLGCOvKGi+JoGuJ/CJInJI7/kLWJqtgGppAKS7ZFLdVOqZYR/uRjxlXOWPnxmUKF8VswOAsq7fXUpNZDhA==} - peerDependencies: - typescript: '>=4.7.2' - zod: ^3.0.0 - peerDependenciesMeta: - typescript: - optional: true - dependencies: - '@t3-oss/env-core': 0.7.1(typescript@5.3.2)(zod@3.22.4) - typescript: 5.3.2 - zod: 3.22.4 - dev: false - - /@tanstack/query-core@5.8.3: - resolution: {integrity: sha512-SWFMFtcHfttLYif6pevnnMYnBvxKf3C+MHMH7bevyYfpXpTMsLB9O6nNGBdWSoPwnZRXFNyNeVZOw25Wmdasow==} - dev: false - - /@tanstack/query-devtools@5.8.4: - resolution: {integrity: sha512-F1dRbITNt9tMUoM9WCH8WQ2c54116hv52m/PKK8ZiN/pO2wGVzTZtKuLanF8pFpwmNchjIixcMw/a57HY5ivcw==} + /@sapphire/utilities@3.18.2: + resolution: {integrity: sha512-QGLdC9+pT74Zd7aaObqn0EUfq40c4dyTL65pFnkM6WO1QYN7Yg/s4CdH+CXmx0Zcu6wcfCWILSftXPMosJHP5A==} + engines: {node: '>=v14.0.0'} dev: false - /@tanstack/react-query-devtools@5.8.4(@tanstack/react-query@5.8.4)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-mffs51FJqXU/5rwhbwv393DccL6et7uK2pRLwOcmMrWbPyW8vpxr9oidaghHX4cdVeP/7u5owW9yMpBhBAJfcQ==} - peerDependencies: - '@tanstack/react-query': ^5.8.4 - react: ^18.0.0 - react-dom: ^18.0.0 + /@so-ric/colorspace@1.1.6: + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} dependencies: - '@tanstack/query-devtools': 5.8.4 - '@tanstack/react-query': 5.8.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@tanstack/react-query-next-experimental@5.8.4(@tanstack/react-query@5.8.4)(next@14.0.3)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-+FfKNLOcjXyFUZHr5z2Wlm/7vJ9VCZUa3ajeOz/1awGSUuGaMyvHGYMO8Pk9YKxg7Fd/lymp1gjOccJcs3vc6g==} - peerDependencies: - '@tanstack/react-query': ^5.8.4 - next: ^13 || ^14 - react: ^18.0.0 - react-dom: ^18.0.0 - dependencies: - '@tanstack/react-query': 5.8.4(react-dom@18.2.0)(react@18.2.0) - next: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@tanstack/react-query@5.8.4(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-CD+AkXzg8J72JrE6ocmuBEJfGzEzu/bzkD6sFXFDDB5yji9N20JofXZlN6n0+CaPJuIi+e4YLCbGsyPFKkfNQA==} - peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - dependencies: - '@tanstack/query-core': 5.8.3 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + color: 5.0.3 + text-hex: 1.0.0 dev: false - /@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106): - resolution: {integrity: sha512-OxgbvwoWgWpijxhtovG4eO9hA+ov/WWtHuwXMwhNt1Jsr5HtHqYnCkU0vaQceponGlvQVPzLYi3zZ7oqQwPFLQ==} - peerDependencies: - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 - dependencies: - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106 - dev: false + /@standard-schema/spec@1.1.0: + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + dev: true - /@trpc/next@11.0.0-alpha-next-2023-11-21-11-13-12.106(@tanstack/react-query@5.8.4)(@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/react-query@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106)(next@14.0.3)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-RcXjvtSYqL241B45ELp1r/k4sFMn2EFxoDL7eT6kC+fIh+gSUNZItDD78Xx/sOMB6KqhAbAjnuYeWP+V8TtBzA==} - peerDependencies: - '@tanstack/react-query': ^5.0.0 - '@trpc/client': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 - '@trpc/react-query': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 - next: '*' - react: '>=16.8.0' - react-dom: '>=16.8.0' - dependencies: - '@tanstack/react-query': 5.8.4(react-dom@18.2.0)(react@18.2.0) - '@trpc/client': 11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106) - '@trpc/react-query': 11.0.0-alpha-next-2023-11-21-11-13-12.106(@tanstack/react-query@5.8.4)(@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106)(react-dom@18.2.0)(react@18.2.0) - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106 - next: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-ssr-prepass: 1.5.0(react@18.2.0) - dev: false - - /@trpc/react-query@11.0.0-alpha-next-2023-11-21-11-13-12.106(@tanstack/react-query@5.8.4)(@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-5oJmgYykcgFwRYEWTm+dzrbK90qxMcT0jvsNyfwJF+Bv47dsZ+MhrvHYhLPzEbiFF594lsgrarAE2ljYj8Im2A==} - peerDependencies: - '@tanstack/react-query': ^5.0.0 - '@trpc/client': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 - react: '>=16.8.0' - react-dom: '>=16.8.0' + /@types/chai@5.2.3: + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} dependencies: - '@tanstack/react-query': 5.8.4(react-dom@18.2.0)(react@18.2.0) - '@trpc/client': 11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106) - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + dev: true - /@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106: - resolution: {integrity: sha512-txzg8RTrZhkTaYOE1vXa99iKDAT6A31e8T34KCpaxY752Wzv9/A9F9AFq+NDN3+PqlIPoCvmUp38dN2BLPk3SQ==} - engines: {node: '>=18.0.0'} - dev: false + /@types/deep-eql@4.0.2: + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + dev: true - /@types/eslint@8.44.7: - resolution: {integrity: sha512-f5ORu2hcBbKei97U73mf+l9t4zTGl74IqZ0GQk4oVea/VS8tQZYkUveSYojk+frraAVYId0V2WC9O4PTNru2FQ==} + /@types/eslint@8.56.12: + resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==} dependencies: '@types/estree': 1.0.1 '@types/json-schema': 7.0.12 dev: false /@types/estree@1.0.1: - resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} - dev: false - - /@types/ioredis@4.28.10: - resolution: {integrity: sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==} - dependencies: - '@types/node': 20.9.3 - dev: true - - /@types/json-schema@7.0.12: - resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} - - /@types/json5@0.0.29: - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - dev: false - - /@types/keyv@3.1.4: - resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - dependencies: - '@types/node': 20.9.3 - dev: false - - /@types/node@20.9.3: - resolution: {integrity: sha512-nk5wXLAXGBKfrhLB0cyHGbSqopS+nz0BUgZkUQqSHSSgdee0kssp1IAqlQOu333bW+gMNs2QREx7iynm19Abxw==} - dependencies: - undici-types: 5.26.5 - - /@types/prop-types@15.7.5: - resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} - - /@types/react-dom@18.2.16: - resolution: {integrity: sha512-766c37araZ9vxtYs25gvY2wNdFWsT2ZiUvOd0zMhTaoGj6B911N8CKQWgXXJoPMLF3J82thpRqQA7Rf3rBwyJw==} - dependencies: - '@types/react': 18.2.38 - - /@types/react@18.2.38: - resolution: {integrity: sha512-cBBXHzuPtQK6wNthuVMV6IjHAFkdl/FOPFIlkd81/Cd1+IqkHu/A+w4g43kaQQoYHik/ruaQBDL72HyCy1vuMw==} - dependencies: - '@types/prop-types': 15.7.5 - '@types/scheduler': 0.16.3 - csstype: 3.1.2 - - /@types/responselike@1.0.0: - resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} - dependencies: - '@types/node': 20.9.3 + resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} dev: false - /@types/scheduler@0.16.3: - resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} + /@types/estree@1.0.9: + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + dev: true + + /@types/json-schema@7.0.12: + resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} - /@types/semver@6.2.3: - resolution: {integrity: sha512-KQf+QAMWKMrtBMsB8/24w53tEsxllMj6TuA80TT/5igJalLI/zm0L3oXRbIAl4Ohfc85gyHX/jhMwsVkmhLU4A==} + /@types/json5@0.0.29: + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: false + /@types/node@22.5.4: + resolution: {integrity: sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==} + dependencies: + undici-types: 6.19.8 + /@types/semver@7.5.0: resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} @@ -2185,14 +1309,14 @@ packages: resolution: {integrity: sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g==} dev: false - /@types/ws@8.5.9: - resolution: {integrity: sha512-jbdrY0a8lxfdTp/+r7Z4CkycbOFN8WX+IOchLJr3juT/xzbJ8URyTVSJ/hvNdadTgM1mnedb47n+Y31GsFnQlg==} + /@types/ws@8.18.1: + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} dependencies: - '@types/node': 20.9.3 + '@types/node': 22.5.4 dev: false - /@typescript-eslint/eslint-plugin@6.12.0(@typescript-eslint/parser@6.12.0)(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-XOpZ3IyJUIV1b15M7HVOpgQxPPF7lGXgsfcEIu3yDxFPaf/xZKt7s9QO/pbk7vpWQyVulpJbu4E5LwpZiQo4kA==} + /@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.9.3): + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha @@ -2203,24 +1327,24 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.6.2 - '@typescript-eslint/parser': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - '@typescript-eslint/scope-manager': 6.12.0 - '@typescript-eslint/type-utils': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - '@typescript-eslint/utils': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - '@typescript-eslint/visitor-keys': 6.12.0 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.3.4 - eslint: 8.54.0 + eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.2.4 natural-compare: 1.4.0 semver: 7.5.4 - ts-api-utils: 1.0.1(typescript@5.3.2) - typescript: 5.3.2 + ts-api-utils: 1.0.1(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - /@typescript-eslint/parser@6.12.0(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-s8/jNFPKPNRmXEnNXfuo1gemBdVmpQsK1pcu+QIvuNJuhFzGrpD7WjOcvDc/+uEdfzSYpNu7U/+MmbScjoQ6vg==} + /@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3): + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -2229,25 +1353,25 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 6.12.0 - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/typescript-estree': 6.12.0(typescript@5.3.2) - '@typescript-eslint/visitor-keys': 6.12.0 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.3.4 - eslint: 8.54.0 - typescript: 5.3.2 + eslint: 8.57.1 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - /@typescript-eslint/scope-manager@6.12.0: - resolution: {integrity: sha512-5gUvjg+XdSj8pcetdL9eXJzQNTl3RD7LgUiYTl8Aabdi8hFkaGSYnaS6BLc0BGNaDH+tVzVwmKtWvu0jLgWVbw==} + /@typescript-eslint/scope-manager@6.21.0: + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/visitor-keys': 6.12.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 - /@typescript-eslint/type-utils@6.12.0(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-WWmRXxhm1X8Wlquj+MhsAG4dU/Blvf1xDgGaYCzfvStP2NwPQh6KBvCDbiOEvaE0filhranjIlK/2fSTVwtBng==} + /@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3): + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -2256,21 +1380,21 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.12.0(typescript@5.3.2) - '@typescript-eslint/utils': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - debug: 4.3.4 - eslint: 8.54.0 - ts-api-utils: 1.0.1(typescript@5.3.2) - typescript: 5.3.2 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + debug: 4.4.3 + eslint: 8.57.1 + ts-api-utils: 1.0.1(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - /@typescript-eslint/types@6.12.0: - resolution: {integrity: sha512-MA16p/+WxM5JG/F3RTpRIcuOghWO30//VEOvzubM8zuOOBYXsP+IfjoCXXiIfy2Ta8FRh9+IO9QLlaFQUU+10Q==} + /@typescript-eslint/types@6.21.0: + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} engines: {node: ^16.0.0 || >=18.0.0} - /@typescript-eslint/typescript-estree@6.12.0(typescript@5.3.2): - resolution: {integrity: sha512-vw9E2P9+3UUWzhgjyyVczLWxZ3GuQNT7QpnIY3o5OMeLO/c8oHljGc8ZpryBMIyympiAAaKgw9e5Hl9dCWFOYw==} + /@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3): + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' @@ -2278,47 +1402,133 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/visitor-keys': 6.12.0 - debug: 4.3.4 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 + debug: 4.4.3 globby: 11.1.0 is-glob: 4.0.3 + minimatch: 9.0.3 semver: 7.5.4 - ts-api-utils: 1.0.1(typescript@5.3.2) - typescript: 5.3.2 + ts-api-utils: 1.0.1(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - /@typescript-eslint/utils@6.12.0(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-LywPm8h3tGEbgfyjYnu3dauZ0U7R60m+miXgKcZS8c7QALO9uWJdvNoP+duKTk2XMWc7/Q3d/QiCuLN9X6SWyQ==} + /@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.3): + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.54.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@types/json-schema': 7.0.12 '@types/semver': 7.5.0 - '@typescript-eslint/scope-manager': 6.12.0 - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/typescript-estree': 6.12.0(typescript@5.3.2) - eslint: 8.54.0 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + eslint: 8.57.1 semver: 7.5.4 transitivePeerDependencies: - supports-color - typescript - /@typescript-eslint/visitor-keys@6.12.0: - resolution: {integrity: sha512-rg3BizTZHF1k3ipn8gfrzDXXSFKyOEB5zxYXInQ6z0hUvmQlhaZQzK+YmHmNViMA9HzW5Q9+bPPt90bU6GQwyw==} + /@typescript-eslint/visitor-keys@6.21.0: + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.12.0 - eslint-visitor-keys: 3.4.2 + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 /@ungap/structured-clone@1.2.0: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher + + /@vitest/coverage-v8@4.1.0(vitest@4.1.0): + resolution: {integrity: sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==} + peerDependencies: + '@vitest/browser': 4.1.0 + vitest: 4.1.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.0 + ast-v8-to-istanbul: 1.0.5 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 4.1.0(@types/node@22.5.4)(vite@8.2.2) + dev: true + + /@vitest/expect@4.1.0: + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + chai: 6.2.2 + tinyrainbow: 3.1.1 + dev: true + + /@vitest/mocker@4.1.0(vite@8.2.2): + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + dependencies: + '@vitest/spy': 4.1.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + vite: 8.2.2(@types/node@22.5.4)(tsx@4.19.1) + dev: true + + /@vitest/pretty-format@4.1.0: + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + dependencies: + tinyrainbow: 3.1.1 + dev: true + + /@vitest/runner@4.1.0: + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + dependencies: + '@vitest/utils': 4.1.0 + pathe: 2.0.3 + dev: true + + /@vitest/snapshot@4.1.0: + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + dependencies: + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 + magic-string: 0.30.21 + pathe: 2.0.3 + dev: true + + /@vitest/spy@4.1.0: + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + dev: true + + /@vitest/utils@4.1.0: + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + dependencies: + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + dev: true - /@vladfrangu/async_event_emitter@2.2.2: - resolution: {integrity: sha512-HIzRG7sy88UZjBJamssEczH5q7t5+axva19UbZLO6u0ySbYPrwzWiXBcC0WuHyhKKoeCyneH+FvYzKQq/zTtkQ==} + /@vladfrangu/async_event_emitter@2.4.7: + resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false @@ -2334,6 +1544,15 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: false + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -2361,6 +1580,7 @@ packages: /any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + dev: true /anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} @@ -2371,27 +1591,14 @@ packages: /arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - - /argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - dependencies: - sprintf-js: 1.0.3 - dev: false + dev: true /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - /aria-hidden@1.2.3: - resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==} - engines: {node: '>=10'} - dependencies: - tslib: 2.6.2 - dev: false - - /aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - dependencies: - dequal: 2.0.3 + /aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} dev: false /array-buffer-byte-length@1.0.0: @@ -2401,51 +1608,55 @@ packages: is-array-buffer: 3.0.2 dev: false - /array-includes@3.1.6: - resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} + /array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 - is-string: 1.0.7 + call-bound: 1.0.4 + is-array-buffer: 3.0.5 dev: false - /array-includes@3.1.7: - resolution: {integrity: sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==} + /array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 - is-string: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 dev: false /array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} - /array.prototype.findlastindex@1.2.3: - resolution: {integrity: sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==} + /array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 - get-intrinsic: 1.2.1 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 dev: false - /array.prototype.flat@1.3.1: - resolution: {integrity: sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==} + /array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 dev: false /array.prototype.flat@1.3.2: @@ -2453,19 +1664,19 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 + define-properties: 1.2.1 es-abstract: 1.22.1 es-shim-unscopables: 1.0.0 dev: false - /array.prototype.flatmap@1.3.1: - resolution: {integrity: sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==} + /array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 dev: false /array.prototype.flatmap@1.3.2: @@ -2473,19 +1684,30 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 + define-properties: 1.2.1 es-abstract: 1.22.1 es-shim-unscopables: 1.0.0 dev: false - /array.prototype.tosorted@1.1.1: - resolution: {integrity: sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==} + /array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 - get-intrinsic: 1.2.1 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + dev: false + + /array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 dev: false /arraybuffer.prototype.slice@1.0.1: @@ -2500,37 +1722,56 @@ packages: is-shared-array-buffer: 1.0.2 dev: false + /arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + dev: false + + /assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + dev: true + /ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} dev: false + /ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + dev: true + /async@3.2.4: resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} dev: false - /asynciterator.prototype@1.0.0: - resolution: {integrity: sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==} - dependencies: - has-symbols: 1.0.3 - dev: false - /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: false - /autoprefixer@10.4.16(postcss@8.4.31): - resolution: {integrity: sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==} + /autoprefixer@10.5.4(postcss@8.5.26): + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: postcss: ^8.1.0 dependencies: - browserslist: 4.22.1 - caniuse-lite: 1.0.30001563 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.0.0 - postcss: 8.4.31 + browserslist: 4.28.8 + caniuse-lite: 1.0.30001810 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.26 postcss-value-parser: 4.2.0 dev: true @@ -2539,8 +1780,15 @@ packages: engines: {node: '>= 0.4'} dev: false - /axe-core@4.7.0: - resolution: {integrity: sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==} + /available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + dependencies: + possible-typed-array-names: 1.1.0 + dev: false + + /axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} dev: false @@ -2552,20 +1800,21 @@ packages: - debug dev: false - /axios@1.6.2: - resolution: {integrity: sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==} + /axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} dependencies: - follow-redirects: 1.15.2 - form-data: 4.0.0 - proxy-from-env: 1.1.0 + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color dev: false - /axobject-query@3.2.1: - resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} - dependencies: - dequal: 2.0.3 + /axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} dev: false /balanced-match@1.0.2: @@ -2575,6 +1824,12 @@ packages: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: false + /baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} + hasBin: true + dev: true + /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -2589,63 +1844,60 @@ packages: balanced-match: 1.0.2 concat-map: 0.0.1 - /braces@3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} - engines: {node: '>=8'} + /brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} dependencies: - fill-range: 7.0.1 + balanced-match: 1.0.2 - /browserslist@4.21.9: - resolution: {integrity: sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true + /braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} dependencies: - caniuse-lite: 1.0.30001517 - electron-to-chromium: 1.4.468 - node-releases: 2.0.13 - update-browserslist-db: 1.0.11(browserslist@4.21.9) - dev: false + fill-range: 7.1.1 - /browserslist@4.22.1: - resolution: {integrity: sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==} + /browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001563 - electron-to-chromium: 1.4.589 - node-releases: 2.0.13 - update-browserslist-db: 1.0.13(browserslist@4.22.1) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.416 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) dev: true - /builtins@1.0.3: - resolution: {integrity: sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==} + /call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 dev: false - /busboy@1.6.0: - resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} - engines: {node: '>=10.16.0'} + /call-bind@1.0.2: + resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} dependencies: - streamsearch: 1.1.0 + function-bind: 1.1.1 + get-intrinsic: 1.2.1 dev: false - /cacheable-request@6.1.0: - resolution: {integrity: sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==} - engines: {node: '>=8'} + /call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} dependencies: - clone-response: 1.0.3 - get-stream: 5.2.0 - http-cache-semantics: 4.1.1 - keyv: 3.1.0 - lowercase-keys: 2.0.0 - normalize-url: 4.5.1 - responselike: 1.0.2 + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 dev: false - /call-bind@1.0.2: - resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} + /call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} dependencies: - function-bind: 1.1.1 - get-intrinsic: 1.2.1 + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 dev: false /callsites@3.1.0: @@ -2655,13 +1907,15 @@ packages: /camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + dev: true - /caniuse-lite@1.0.30001517: - resolution: {integrity: sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==} - dev: false + /caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + dev: true - /caniuse-lite@1.0.30001563: - resolution: {integrity: sha512-na2WUmOxnwIZtwnFI2CZ/3er0wdNzU7hN+cPYz/z2ajHThnkWjNBOpEPP4n+4r2WPM847JaMotaJE3bnfzjyKw==} + /chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} dev: true /chalk@2.4.2: @@ -2709,40 +1963,30 @@ packages: engines: {node: '>= 8.10.0'} dependencies: anymatch: 3.1.3 - braces: 3.0.2 + braces: 3.0.3 glob-parent: 5.1.2 is-binary-path: 2.1.0 is-glob: 4.0.3 normalize-path: 3.0.0 readdirp: 3.6.0 optionalDependencies: - fsevents: 2.3.2 - - /class-variance-authority@0.7.0: - resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} - dependencies: - clsx: 2.0.0 - dev: false - - /client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + fsevents: 2.3.3 dev: false - /clone-response@1.0.3: - resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + /chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} dependencies: - mimic-response: 1.0.1 - dev: false - - /clsx@2.0.0: - resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} - engines: {node: '>=6'} - dev: false - - /cluster-key-slot@1.1.2: - resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} - engines: {node: '>=0.10.0'} - dev: false + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -2756,6 +2000,13 @@ packages: dependencies: color-name: 1.1.4 + /color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + dependencies: + color-name: 2.1.1 + dev: false + /color-name@1.1.3: resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} dev: false @@ -2763,31 +2014,30 @@ packages: /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - /color-string@1.9.1: - resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + /color-name@2.1.1: + resolution: {integrity: sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==} + engines: {node: '>=12.20'} + dev: false + + /color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} dependencies: - color-name: 1.1.4 - simple-swizzle: 0.2.2 + color-name: 2.1.1 dev: false - /color@3.2.1: - resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + /color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} dependencies: - color-convert: 1.9.3 - color-string: 1.9.1 + color-convert: 3.1.3 + color-string: 2.1.4 dev: false /colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} dev: false - /colorspace@1.1.4: - resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} - dependencies: - color: 3.2.1 - text-hex: 1.0.0 - dev: false - /combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -2798,33 +2048,21 @@ packages: /commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + dev: true /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - /convert-source-map@1.9.0: - resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} - dev: false - - /cookie@0.5.0: - resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} - engines: {node: '>= 0.6'} - dev: false - - /copy-anything@3.0.5: - resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} - engines: {node: '>=12.13'} + /config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} dependencies: - is-what: 4.1.15 - dev: false + ini: 1.3.8 + proto-list: 1.2.4 + dev: true - /cross-spawn@5.1.0: - resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} - dependencies: - lru-cache: 4.1.5 - shebang-command: 1.2.0 - which: 1.3.1 - dev: false + /convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + dev: true /cross-spawn@6.0.5: resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==} @@ -2845,6 +2083,15 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + /css-select@5.1.0: resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} dependencies: @@ -2864,17 +2111,37 @@ packages: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true - - /csstype@3.1.2: - resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} + dev: true /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} dev: false - /data-uri-to-buffer@4.0.1: - resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} - engines: {node: '>= 12'} + /data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dev: false + + /data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dev: false + + /data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 dev: false /debug@3.2.7: @@ -2899,34 +2166,43 @@ packages: dependencies: ms: 2.1.2 - /decompress-response@3.3.0: - resolution: {integrity: sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==} - engines: {node: '>=4'} + /debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true dependencies: - mimic-response: 1.0.1 - dev: false + ms: 2.1.3 /deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} - dev: false + dev: true /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - /defer-to-connect@1.1.3: - resolution: {integrity: sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==} - dev: false - /define-data-property@1.1.1: resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==} engines: {node: '>= 0.4'} dependencies: - get-intrinsic: 1.2.1 + get-intrinsic: 1.3.0 gopd: 1.0.1 has-property-descriptors: 1.0.0 dev: false + /define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + dev: false + /define-properties@1.2.0: resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} engines: {node: '>= 0.4'} @@ -2949,27 +2225,19 @@ packages: engines: {node: '>=0.4.0'} dev: false - /denque@2.1.0: - resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} - engines: {node: '>=0.10'} - dev: false - - /dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - dev: false + /detect-indent@7.0.2: + resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} + engines: {node: '>=12.20'} + dev: true - /detect-indent@6.1.0: - resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + /detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - dev: false - - /detect-node-es@1.1.0: - resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - dev: false + dev: true /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dev: true /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} @@ -2977,32 +2245,35 @@ packages: dependencies: path-type: 4.0.0 + /discord-api-types@0.37.120: + resolution: {integrity: sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==} + dev: false + /discord-api-types@0.37.61: resolution: {integrity: sha512-o/dXNFfhBpYHpQFdT6FWzeO7pKc838QeeZ9d91CfVAtpr5XLK4B/zYxQbYgPdoMiTDvJfzcsLW5naXgmHGDNXw==} dev: false - /discord-api-types@0.37.64: - resolution: {integrity: sha512-9aS+QuoNj+4e9d5uDKfds1DCpQLYn/mHx+M8OFHZ/ZZJVadZJEo275uBOaSsw5KGYGsZ4hxMzlOkIxnWirgqKA==} + /discord-api-types@0.38.54: + resolution: {integrity: sha512-3704EKdPtVl0Mozoe6uBJQ8GNmUkH8c81nfXkdSBx+Um8GN3FI/003uTY0Pg0A4KjL8g2Q+4v5cHR247kv6vwA==} dev: false - /discord.js@14.14.1: - resolution: {integrity: sha512-/hUVzkIerxKHyRKopJy5xejp4MYKDPTszAnpYxzVVv4qJYf+Tkt+jnT2N29PIPschicaEEpXwF2ARrTYHYwQ5w==} - engines: {node: '>=16.11.0'} + /discord.js@14.27.0: + resolution: {integrity: sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==} + engines: {node: '>=18'} dependencies: - '@discordjs/builders': 1.7.0 + '@discordjs/builders': 1.14.1 '@discordjs/collection': 1.5.3 - '@discordjs/formatters': 0.3.3 - '@discordjs/rest': 2.2.0 - '@discordjs/util': 1.0.2 - '@discordjs/ws': 1.0.2 - '@sapphire/snowflake': 3.5.1 - '@types/ws': 8.5.9 - discord-api-types: 0.37.61 + '@discordjs/formatters': 0.6.2 + '@discordjs/rest': 2.6.3 + '@discordjs/util': 1.2.0 + '@discordjs/ws': 1.2.3 + '@sapphire/snowflake': 3.5.5 + discord-api-types: 0.38.54 fast-deep-equal: 3.1.3 lodash.snakecase: 4.1.1 - tslib: 2.6.2 - undici: 5.27.2 - ws: 8.14.2 + magic-bytes.js: 1.13.1 + tslib: 2.8.1 + undici: 6.28.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -3010,6 +2281,7 @@ packages: /dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dev: true /doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} @@ -3051,12 +2323,12 @@ packages: domhandler: 5.0.3 dev: false - /dotenv-cli@7.3.0: - resolution: {integrity: sha512-314CA4TyK34YEJ6ntBf80eUY+t1XaFLyem1k9P0sX1gn30qThZ5qZr/ZwE318gEnzyYP9yj9HJk6SqwE0upkfw==} + /dotenv-cli@7.4.4: + resolution: {integrity: sha512-XkBYCG0tPIes+YZr4SpfFv76SQrV/LeCE8CI7JSEMi3VR9MvTihCGTOtbIexD6i2mXF+6px7trb1imVCXSNMDw==} hasBin: true dependencies: - cross-spawn: 7.0.3 - dotenv: 16.3.1 + cross-spawn: 7.0.6 + dotenv: 16.6.1 dotenv-expand: 10.0.0 minimist: 1.2.8 dev: true @@ -3071,21 +2343,21 @@ packages: engines: {node: '>=12'} dev: false - /dotenv@16.3.1: - resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} + /dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - dev: true - - /duplexer3@0.1.5: - resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} - dev: false - /electron-to-chromium@1.4.468: - resolution: {integrity: sha512-6M1qyhaJOt7rQtNti1lBA0GwclPH+oKCmsra/hkcWs5INLxfXXD/dtdnaKUYQu/pjOBP/8Osoe4mAcNvvzoFag==} + /dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 dev: false - /electron-to-chromium@1.4.589: - resolution: {integrity: sha512-zF6y5v/YfoFIgwf2dDfAqVlPPsyQeWNpEWXbAlDUS8Ax4Z2VoiiZpAPC0Jm9hXEkJm2vIZpwB6rc4KnLTQffbQ==} + /electron-to-chromium@1.5.416: + resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==} dev: true /emoji-regex@9.2.2: @@ -3096,12 +2368,6 @@ packages: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} dev: false - /end-of-stream@1.4.4: - resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} - dependencies: - once: 1.4.0 - dev: false - /entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -3113,6 +2379,16 @@ packages: is-arrayish: 0.2.1 dev: false + /es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + dev: false + /es-abstract@1.22.1: resolution: {integrity: sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==} engines: {node: '>= 0.4'} @@ -3158,23 +2434,107 @@ packages: which-typed-array: 1.1.11 dev: false - /es-iterator-helpers@1.0.15: - resolution: {integrity: sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==} + /es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.2 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + dev: false + + /es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + dev: false + + /es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + dev: false + + /es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} + engines: {node: '>= 0.4'} dependencies: - asynciterator.prototype: 1.0.0 - call-bind: 1.0.2 + call-bind: 1.0.9 + call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.22.1 - es-set-tostringtag: 2.0.1 - function-bind: 1.1.1 - get-intrinsic: 1.2.1 - globalthis: 1.0.3 - has-property-descriptors: 1.0.0 - has-proto: 1.0.1 - has-symbols: 1.0.3 - internal-slot: 1.0.5 - iterator.prototype: 1.1.2 - safe-array-concat: 1.0.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + dev: false + + /es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + dev: true + + /es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 dev: false /es-set-tostringtag@2.0.1: @@ -3186,12 +2546,29 @@ packages: has-tostringtag: 1.0.0 dev: false + /es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + dev: false + /es-shim-unscopables@1.0.0: resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} dependencies: has: 1.0.3 dev: false + /es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + dependencies: + hasown: 2.0.4 + dev: false + /es-to-primitive@1.2.1: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} @@ -3201,9 +2578,54 @@ packages: is-symbol: 1.0.4 dev: false - /escalade@3.1.1: - resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + /es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} + engines: {node: '>= 0.4'} + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + dev: false + + /esbuild@0.23.1: + resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.23.1 + '@esbuild/android-arm': 0.23.1 + '@esbuild/android-arm64': 0.23.1 + '@esbuild/android-x64': 0.23.1 + '@esbuild/darwin-arm64': 0.23.1 + '@esbuild/darwin-x64': 0.23.1 + '@esbuild/freebsd-arm64': 0.23.1 + '@esbuild/freebsd-x64': 0.23.1 + '@esbuild/linux-arm': 0.23.1 + '@esbuild/linux-arm64': 0.23.1 + '@esbuild/linux-ia32': 0.23.1 + '@esbuild/linux-loong64': 0.23.1 + '@esbuild/linux-mips64el': 0.23.1 + '@esbuild/linux-ppc64': 0.23.1 + '@esbuild/linux-riscv64': 0.23.1 + '@esbuild/linux-s390x': 0.23.1 + '@esbuild/linux-x64': 0.23.1 + '@esbuild/netbsd-x64': 0.23.1 + '@esbuild/openbsd-arm64': 0.23.1 + '@esbuild/openbsd-x64': 0.23.1 + '@esbuild/sunos-x64': 0.23.1 + '@esbuild/win32-arm64': 0.23.1 + '@esbuild/win32-ia32': 0.23.1 + '@esbuild/win32-x64': 0.23.1 + dev: true + + /escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + dev: true /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} @@ -3214,36 +2636,36 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - /eslint-config-prettier@9.0.0(eslint@8.54.0): - resolution: {integrity: sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==} + /eslint-config-prettier@9.1.2(eslint@8.57.1): + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} hasBin: true peerDependencies: eslint: '>=7.0.0' dependencies: - eslint: 8.54.0 + eslint: 8.57.1 dev: false - /eslint-config-turbo@1.10.16(eslint@8.54.0): - resolution: {integrity: sha512-O3NQI72bQHV7FvSC6lWj66EGx8drJJjuT1kuInn6nbMLOHdMBhSUX/8uhTAlHRQdlxZk2j9HtgFCIzSc93w42g==} + /eslint-config-turbo@1.13.4(eslint@8.57.1): + resolution: {integrity: sha512-+we4eWdZlmlEn7LnhXHCIPX/wtujbHCS7XjQM/TN09BHNEl2fZ8id4rHfdfUKIYTSKyy8U/nNyJ0DNoZj5Q8bw==} peerDependencies: eslint: '>6.6.0' dependencies: - eslint: 8.54.0 - eslint-plugin-turbo: 1.10.16(eslint@8.54.0) + eslint: 8.57.1 + eslint-plugin-turbo: 1.13.4(eslint@8.57.1) dev: false /eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} dependencies: debug: 3.2.7 - is-core-module: 2.13.1 + is-core-module: 2.16.2 resolve: 1.22.8 transitivePeerDependencies: - supports-color dev: false - /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.12.0)(eslint-import-resolver-node@0.3.9)(eslint@8.54.0): - resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} + /eslint-module-utils@2.14.0(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -3263,115 +2685,118 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 6.12.0(eslint@8.54.0)(typescript@5.3.2) + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) debug: 3.2.7 - eslint: 8.54.0 + eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color dev: false - /eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.12.0)(eslint@8.54.0): - resolution: {integrity: sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==} + /eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1): + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 peerDependenciesMeta: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - array-includes: 3.1.7 - array.prototype.findlastindex: 1.2.3 - array.prototype.flat: 1.3.2 - array.prototype.flatmap: 1.3.2 + '@rtsao/scc': 1.1.0 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 8.54.0 + eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.12.0)(eslint-import-resolver-node@0.3.9)(eslint@8.54.0) - hasown: 2.0.0 - is-core-module: 2.13.1 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) + hasown: 2.0.4 + is-core-module: 2.16.2 is-glob: 4.0.3 minimatch: 3.1.2 - object.fromentries: 2.0.7 - object.groupby: 1.0.1 - object.values: 1.1.7 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 semver: 6.3.1 - tsconfig-paths: 3.14.2 + string.prototype.trimend: 1.0.10 + tsconfig-paths: 3.15.0 transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color dev: false - /eslint-plugin-jsx-a11y@6.8.0(eslint@8.54.0): - resolution: {integrity: sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==} + /eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1): + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} engines: {node: '>=4.0'} peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 dependencies: - '@babel/runtime': 7.23.4 - aria-query: 5.3.0 - array-includes: 3.1.7 + aria-query: 5.3.2 + array-includes: 3.1.9 array.prototype.flatmap: 1.3.2 ast-types-flow: 0.0.8 - axe-core: 4.7.0 - axobject-query: 3.2.1 + axe-core: 4.13.0 + axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - es-iterator-helpers: 1.0.15 - eslint: 8.54.0 - hasown: 2.0.0 + eslint: 8.57.1 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.2 - object.entries: 1.1.7 - object.fromentries: 2.0.7 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 dev: false - /eslint-plugin-react-hooks@4.6.0(eslint@8.54.0): - resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} + /eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} engines: {node: '>=10'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 dependencies: - eslint: 8.54.0 + eslint: 8.57.1 dev: false - /eslint-plugin-react@7.33.2(eslint@8.54.0): - resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==} + /eslint-plugin-react@7.37.5(eslint@8.57.1): + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} engines: {node: '>=4'} peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 dependencies: - array-includes: 3.1.6 - array.prototype.flatmap: 1.3.1 - array.prototype.tosorted: 1.1.1 + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.0.15 - eslint: 8.54.0 + es-iterator-helpers: 1.4.0 + eslint: 8.57.1 estraverse: 5.3.0 - jsx-ast-utils: 3.3.4 + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 minimatch: 3.1.2 - object.entries: 1.1.6 - object.fromentries: 2.0.6 - object.hasown: 1.1.2 - object.values: 1.1.6 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.4 + resolve: 2.0.0-next.7 semver: 6.3.1 - string.prototype.matchall: 4.0.8 + string.prototype.matchall: 4.1.0 + string.prototype.repeat: 1.0.0 dev: false - /eslint-plugin-turbo@1.10.16(eslint@8.54.0): - resolution: {integrity: sha512-ZjrR88MTN64PNGufSEcM0tf+V1xFYVbeiMeuIqr0aiABGomxFLo4DBkQ7WI4WzkZtWQSIA2sP+yxqSboEfL9MQ==} + /eslint-plugin-turbo@1.13.4(eslint@8.57.1): + resolution: {integrity: sha512-82GfMzrewI/DJB92Bbch239GWbGx4j1zvjk1lqb06lxIlMPnVwUHVwPbAnLfyLG3JuhLv9whxGkO/q1CL18JTg==} peerDependencies: eslint: '>6.6.0' dependencies: dotenv: 16.0.3 - eslint: 8.54.0 + eslint: 8.57.1 dev: false /eslint-scope@7.2.2: @@ -3381,24 +2806,21 @@ packages: esrecurse: 4.3.0 estraverse: 5.3.0 - /eslint-visitor-keys@3.4.2: - resolution: {integrity: sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - /eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - /eslint@8.54.0: - resolution: {integrity: sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==} + /eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.54.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@eslint-community/regexpp': 4.6.2 - '@eslint/eslintrc': 2.1.3 - '@eslint/js': 8.54.0 - '@humanwhocodes/config-array': 0.11.13 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.2.0 @@ -3443,12 +2865,6 @@ packages: acorn-jsx: 5.3.2(acorn@8.10.0) eslint-visitor-keys: 3.4.3 - /esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - dev: false - /esquery@1.5.0: resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} engines: {node: '>=0.10'} @@ -3465,10 +2881,21 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.9 + dev: true + /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + /expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + dev: true + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3480,7 +2907,18 @@ packages: '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.5 + micromatch: 4.0.8 + dev: false + + /fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3493,18 +2931,22 @@ packages: dependencies: reusify: 1.0.4 + /fdir@6.5.0(picomatch@4.0.7): + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + dependencies: + picomatch: 4.0.7 + dev: true + /fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} dev: false - /fetch-blob@3.2.0: - resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} - engines: {node: ^12.20 || >= 14.13} - dependencies: - node-domexception: 1.0.0 - web-streams-polyfill: 3.2.1 - dev: false - /file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3517,20 +2959,12 @@ packages: moment: 2.29.4 dev: false - /fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + /fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 - /find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - dev: false - /find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3562,46 +2996,49 @@ packages: optional: true dev: false + /follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dev: false + /for-each@0.3.3: resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} dependencies: is-callable: 1.2.7 dev: false - /form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + /for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + dependencies: + is-callable: 1.2.7 + dev: false + + /form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 mime-types: 2.1.35 dev: false - /formdata-polyfill@4.0.10: - resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} - engines: {node: '>=12.20.0'} - dependencies: - fetch-blob: 3.2.0 - dev: false - - /fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + /fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} dev: true - /fs-extra@8.1.0: - resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} - engines: {node: '>=6 <7 || >=8'} - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - dev: false - /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true @@ -3609,10 +3046,10 @@ packages: /function-bind@1.1.1: resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + dev: false /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - dev: false /function.prototype.name@1.1.5: resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} @@ -3624,6 +3061,21 @@ packages: functions-have-names: 1.2.3 dev: false + /function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + dev: false + /functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: false @@ -3637,11 +3089,6 @@ packages: - debug dev: false - /gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - dev: false - /get-intrinsic@1.2.1: resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} dependencies: @@ -3651,23 +3098,28 @@ packages: has-symbols: 1.0.3 dev: false - /get-nonce@1.0.1: - resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} - engines: {node: '>=6'} - dev: false - - /get-stream@4.1.0: - resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} - engines: {node: '>=6'} + /get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} dependencies: - pump: 3.0.0 + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 dev: false - /get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} + /get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} dependencies: - pump: 3.0.0 + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 dev: false /get-symbol-description@1.0.0: @@ -3678,6 +3130,21 @@ packages: get-intrinsic: 1.2.1 dev: false + /get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + dev: false + + /get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + dependencies: + resolve-pkg-maps: 1.0.0 + dev: true + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3690,33 +3157,9 @@ packages: dependencies: is-glob: 4.0.3 - /glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - dev: false - - /glob@7.1.6: - resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - /glob@7.1.7: - resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - dev: false - /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -3725,11 +3168,6 @@ packages: once: 1.4.0 path-is-absolute: 1.0.1 - /globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} - dev: false - /globals@13.20.0: resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} engines: {node: '>=8'} @@ -3743,20 +3181,28 @@ packages: define-properties: 1.2.0 dev: false + /globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + dev: false + /globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} dependencies: array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.3.1 + fast-glob: 3.3.3 ignore: 5.2.4 merge2: 1.4.1 slash: 3.0.0 - /google-translate-api-x@10.6.7: - resolution: {integrity: sha512-xw20Kjv5u84Q3FwKTk4CU1PZrYoOsRcKq0z7J3a98aIcscOCZnW3T43Rb/SOl/JPUj/QYyjJiRW9G+MQhxUnAw==} - engines: {node: '>=14.0.0'} + /google-translate-api-x@10.7.3: + resolution: {integrity: sha512-UCLhGMyzUiQQJuUjw6KM4scl4WJjMq5kYbq3eqLHB0KB96XBQ+VV7L/NUpJ9eMfVxXswXxA6xWhAX1h1OdDLZg==} + engines: {node: '>=21.0.0'} dev: false /gopd@1.0.1: @@ -3765,25 +3211,15 @@ packages: get-intrinsic: 1.2.1 dev: false - /got@9.6.0: - resolution: {integrity: sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==} - engines: {node: '>=8.6'} - dependencies: - '@sindresorhus/is': 0.14.0 - '@szmarczak/http-timer': 1.1.2 - '@types/keyv': 3.1.4 - '@types/responselike': 1.0.0 - cacheable-request: 6.1.0 - decompress-response: 3.3.0 - duplexer3: 0.1.5 - get-stream: 4.1.0 - lowercase-keys: 1.0.1 - mimic-response: 1.0.1 - p-cancelable: 1.1.0 - to-readable-stream: 1.0.0 - url-parse-lax: 3.0.0 + /gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} dev: false + /graceful-fs@4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + dev: true + /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} dev: false @@ -3810,16 +3246,34 @@ packages: get-intrinsic: 1.2.1 dev: false + /has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + dependencies: + es-define-property: 1.0.1 + dev: false + /has-proto@1.0.1: resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} engines: {node: '>= 0.4'} dev: false + /has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + dependencies: + dunder-proto: 1.0.1 + dev: false + /has-symbols@1.0.3: resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} engines: {node: '>= 0.4'} dev: false + /has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + dev: false + /has-tostringtag@1.0.0: resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} engines: {node: '>= 0.4'} @@ -3827,23 +3281,41 @@ packages: has-symbols: 1.0.3 dev: false + /has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + dev: false + /has@1.0.3: resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} engines: {node: '>= 0.4.0'} dependencies: function-bind: 1.1.1 + dev: false /hasown@2.0.0: resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} engines: {node: '>= 0.4'} dependencies: function-bind: 1.1.2 + + /hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 dev: false /hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} dev: false + /html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + dev: true + /htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} dependencies: @@ -3853,8 +3325,14 @@ packages: entities: 4.5.0 dev: false - /http-cache-semantics@4.1.1: - resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color dev: false /ignore@5.2.4: @@ -3874,6 +3352,7 @@ packages: /inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. dependencies: once: 1.4.0 wrappy: 1.0.2 @@ -3883,7 +3362,7 @@ packages: /ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - dev: false + dev: true /internal-slot@1.0.5: resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} @@ -3894,27 +3373,13 @@ packages: side-channel: 1.0.4 dev: false - /invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - dependencies: - loose-envify: 1.4.0 - dev: false - - /ioredis@5.3.2: - resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} - engines: {node: '>=12.22.0'} + /internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} dependencies: - '@ioredis/commands': 1.2.0 - cluster-key-slot: 1.1.2 - debug: 4.3.4 - denque: 2.1.0 - lodash.defaults: 4.2.0 - lodash.isarguments: 3.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 dev: false /is-array-buffer@3.0.2: @@ -3925,19 +3390,24 @@ packages: is-typed-array: 1.1.12 dev: false - /is-arrayish@0.2.1: - resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + /is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 dev: false - /is-arrayish@0.3.2: - resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + /is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} dev: false /is-async-function@2.0.0: resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: false /is-bigint@1.0.4: @@ -3946,6 +3416,13 @@ packages: has-bigints: 1.0.2 dev: false + /is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + dependencies: + has-bigints: 1.0.2 + dev: false + /is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -3960,20 +3437,38 @@ packages: has-tostringtag: 1.0.0 dev: false + /is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: false + /is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} dev: false - /is-core-module@2.12.1: - resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} - dependencies: - has: 1.0.3 - /is-core-module@2.13.1: resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} dependencies: hasown: 2.0.0 + + /is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + dependencies: + hasown: 2.0.4 + dev: false + + /is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 dev: false /is-date-object@1.0.5: @@ -3983,21 +3478,37 @@ packages: has-tostringtag: 1.0.0 dev: false + /is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: false + + /is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + dev: false + /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - /is-finalizationregistry@1.0.2: - resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + /is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 + call-bound: 1.0.4 dev: false /is-generator-function@1.0.10: resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: false /is-glob@4.0.3: @@ -4006,8 +3517,9 @@ packages: dependencies: is-extglob: 2.1.1 - /is-map@2.0.2: - resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} + /is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} dev: false /is-negative-zero@2.0.2: @@ -4015,6 +3527,11 @@ packages: engines: {node: '>= 0.4'} dev: false + /is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + dev: false + /is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} @@ -4022,6 +3539,14 @@ packages: has-tostringtag: 1.0.0 dev: false + /is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: false + /is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} @@ -4038,8 +3563,19 @@ packages: has-tostringtag: 1.0.0 dev: false - /is-set@2.0.2: - resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} + /is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + dev: false + + /is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} dev: false /is-shared-array-buffer@1.0.2: @@ -4048,6 +3584,13 @@ packages: call-bind: 1.0.2 dev: false + /is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + dev: false + /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -4060,6 +3603,14 @@ packages: has-tostringtag: 1.0.0 dev: false + /is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: false + /is-symbol@1.0.4: resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} engines: {node: '>= 0.4'} @@ -4067,6 +3618,15 @@ packages: has-symbols: 1.0.3 dev: false + /is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + dev: false + /is-typed-array@1.1.12: resolution: {integrity: sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==} engines: {node: '>= 0.4'} @@ -4074,8 +3634,16 @@ packages: which-typed-array: 1.1.11 dev: false - /is-weakmap@2.0.1: - resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} + /is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + dependencies: + which-typed-array: 1.1.22 + dev: false + + /is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} dev: false /is-weakref@1.0.2: @@ -4084,16 +3652,19 @@ packages: call-bind: 1.0.2 dev: false - /is-weakset@2.0.2: - resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} + /is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.1 + call-bound: 1.0.4 dev: false - /is-what@4.1.15: - resolution: {integrity: sha512-uKua1wfy3Yt+YqsD6mTUEa2zSi3G1oPlqTflgaPJ7z63vUGN5pxFpnQfeSLMFnJDEsdvOtkp1rUWkYjB4YfhgA==} - engines: {node: '>=12.13'} + /is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 dev: false /isarray@2.0.5: @@ -4103,48 +3674,60 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - /iso-639-1@3.1.0: - resolution: {integrity: sha512-rWcHp9dcNbxa5C8jA/cxFlWNFNwy5Vup0KcFvgA8sPQs9ZeJHj/Eq0Y8Yz2eL8XlWYpxw4iwh9FfTeVxyqdRMw==} + /iso-639-1@3.1.6: + resolution: {integrity: sha512-ZFar/L4ngX7wZh2QX+Fiftmuf0igWJsrJtfizrovWifF1gAWkfmRa5Z1m0LQZbm0hKCHRDYhLRSLFrSqNe4EJA==} engines: {node: '>=6.0'} dev: false - /iterator.prototype@1.1.2: - resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + /istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + dev: true + + /istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + dev: true + + /istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} dependencies: - define-properties: 1.2.1 - get-intrinsic: 1.2.1 - has-symbols: 1.0.3 - reflect.getprototypeof: 1.0.4 - set-function-name: 2.0.1 + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + dev: true + + /iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 dev: false - /jiti@1.19.1: - resolution: {integrity: sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg==} + /jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true + dev: true /jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} - dev: false - - /jose@4.15.4: - resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==} - dev: false + dev: true - /jose@5.1.1: - resolution: {integrity: sha512-bfB+lNxowY49LfrBO0ITUn93JbUhxUN8I11K6oI5hJu/G6PO6fEUddVLjqdD0cQ9SXIHWXuWh7eJYwZF7Z0N/g==} - dev: false + /js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + dev: true /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: false - - /js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} - hasBin: true - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - dev: false /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} @@ -4152,15 +3735,11 @@ packages: dependencies: argparse: 2.0.1 - /jsesc@2.5.2: - resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} - engines: {node: '>=4'} + /jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} hasBin: true - dev: false - - /json-buffer@3.0.0: - resolution: {integrity: sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==} - dev: false + dev: true /json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} @@ -4179,48 +3758,25 @@ packages: minimist: 1.2.8 dev: false - /json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - dev: false - - /jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - optionalDependencies: - graceful-fs: 4.2.11 - dev: false - - /jsx-ast-utils@3.3.4: - resolution: {integrity: sha512-fX2TVdCViod6HwKEtSWGHs57oFhVfCMwieb9PuRDgjDPh5XeqJiHFFFJCHxU5cnTc3Bu/GRL+kPiFmw8XWOfKw==} - engines: {node: '>=4.0'} - dependencies: - array-includes: 3.1.6 - array.prototype.flat: 1.3.1 - object.assign: 4.1.4 - object.values: 1.1.6 - dev: false - /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} dependencies: - array-includes: 3.1.7 - array.prototype.flat: 1.3.1 + array-includes: 3.1.9 + array.prototype.flat: 1.3.2 object.assign: 4.1.4 - object.values: 1.1.6 - dev: false - - /keyv@3.1.0: - resolution: {integrity: sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==} - dependencies: - json-buffer: 3.0.0 + object.values: 1.1.7 dev: false /kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} dev: false + /ky@1.14.3: + resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} + engines: {node: '>=18'} + dev: true + /language-subtag-registry@0.3.22: resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} dev: false @@ -4232,14 +3788,12 @@ packages: language-subtag-registry: 0.3.22 dev: false - /lavaclient@4.1.1: - resolution: {integrity: sha512-j2X7zYGv6WNf4KWNSc6Vl5emSVmNaAsCLbOuUdAeuRzNZ+2JLhXGhBy00m4Qct/wvDkRzujVSVrR7powY6jPUA==} - engines: {node: '>=16.x.x'} + /lavalink-client@2.2.0: + resolution: {integrity: sha512-en5bYBx2avDHaf/vfn0h4E1QGQ5y0PwafDiN+2cDun9CcZOutyi8WaqTkMKwJ0CpwYztHfuF3I8YshlHIvNrSw==} + engines: {node: '>=18.0.0'} dependencies: - '@lavaclient/types': 2.1.1 - tiny-typed-emitter: 2.1.0 - undici: 5.22.1 - ws: 8.13.0 + tslib: 2.8.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -4252,12 +3806,132 @@ packages: prelude-ls: 1.2.1 type-check: 0.4.0 - /lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} + /lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + dev: true + + /lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + dev: true /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + dev: true /load-json-file@4.0.0: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} @@ -4269,27 +3943,12 @@ packages: strip-bom: 3.0.0 dev: false - /locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} - dependencies: - p-locate: 4.1.0 - dev: false - /locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} dependencies: p-locate: 5.0.0 - /lodash.defaults@4.2.0: - resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - dev: false - - /lodash.isarguments@3.1.0: - resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} - dev: false - /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -4301,10 +3960,11 @@ packages: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} dev: false - /logform@2.5.1: - resolution: {integrity: sha512-9FyqAm9o9NKKfiAKfZoYo9bGXXuwMkxQiQttkT4YjjVtQVIQtK6LmVtlxmCaFswo6N4AfEkHqZTV0taDtPotNg==} + /logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} dependencies: - '@colors/colors': 1.5.0 + '@colors/colors': 1.6.0 '@types/triple-beam': 1.3.2 fecha: 4.2.3 ms: 2.1.3 @@ -4319,45 +3979,40 @@ packages: js-tokens: 4.0.0 dev: false - /lowercase-keys@1.0.1: - resolution: {integrity: sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==} - engines: {node: '>=0.10.0'} - dev: false + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 - /lowercase-keys@2.0.0: - resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} - engines: {node: '>=8'} + /magic-bytes.js@1.13.1: + resolution: {integrity: sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==} dev: false - /lru-cache@4.1.5: - resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} + /magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} dependencies: - pseudomap: 1.0.2 - yallist: 2.1.2 - dev: false + '@jridgewell/sourcemap-codec': 1.6.0 + dev: true - /lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + /magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} dependencies: - yallist: 3.1.1 - dev: false + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + dev: true - /lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + /make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} dependencies: - yallist: 4.0.0 - - /lucide-react@0.292.0(react@18.2.0): - resolution: {integrity: sha512-rRgUkpEHWpa5VCT66YscInCQmQuPCB1RFRzkkxMxg4b+jaL0V12E3riWWR2Sh5OIiUhCwGW/ZExuEO4Az32E6Q==} - peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 - dependencies: - react: 18.2.0 - dev: false + semver: 7.8.5 + dev: true - /magic-bytes.js@1.5.0: - resolution: {integrity: sha512-wJkXvutRbNWcc37tt5j1HyOK1nosspdh3dj6LUYYAvF6JYNqs53IfRvK9oEpcwiDA1NdoIi64yAMfdivPeVAyw==} + /math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} dev: false /memorystream@0.3.1: @@ -4374,11 +4029,11 @@ packages: engines: {node: '>=10.0.0'} dev: false - /micromatch@4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + /micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} dependencies: - braces: 3.0.2 + braces: 3.0.3 picomatch: 2.3.1 /mime-db@1.52.0: @@ -4393,16 +4048,17 @@ packages: mime-db: 1.52.0 dev: false - /mimic-response@1.0.1: - resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} - engines: {node: '>=4'} - dev: false - /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: brace-expansion: 1.1.11 + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.1.4 + /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} @@ -4415,7 +4071,6 @@ packages: /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: false /mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -4423,11 +4078,13 @@ packages: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 + dev: true - /nanoid@3.3.6: - resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + /nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + dev: true /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -4437,92 +4094,24 @@ packages: hasBin: true dev: false - /next-auth@5.0.0-beta.3(next@14.0.3)(react@18.2.0): - resolution: {integrity: sha512-WOKhATBFGeONV+29HzFmspNmL7NXxrsCWLfaDKmAd/4DD1nqXE0BzNFH8t3SJBx7PUDMnB6F7xB76LM/AaV1MQ==} - peerDependencies: - next: ^14 - nodemailer: ^6.6.5 - react: ^18.2.0 - peerDependenciesMeta: - nodemailer: - optional: true - dependencies: - '@auth/core': 0.0.0-manual.e9863699 - next: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - dev: false - - /next-themes@0.2.1(next@14.0.3)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} - peerDependencies: - next: '*' - react: '*' - react-dom: '*' - dependencies: - next: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /next@14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-AbYdRNfImBr3XGtvnwOxq8ekVCwbFTv/UJoLwmaX89nk9i051AEY4/HAWzU0YpaTDw8IofUpmuIlvzWF13jxIw==} - engines: {node: '>=18.17.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - react: ^18.2.0 - react-dom: ^18.2.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - sass: - optional: true - dependencies: - '@next/env': 14.0.3 - '@swc/helpers': 0.5.2 - busboy: 1.6.0 - caniuse-lite: 1.0.30001517 - postcss: 8.4.31 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.1(@babel/core@7.22.9)(react@18.2.0) - watchpack: 2.4.0 - optionalDependencies: - '@next/swc-darwin-arm64': 14.0.3 - '@next/swc-darwin-x64': 14.0.3 - '@next/swc-linux-arm64-gnu': 14.0.3 - '@next/swc-linux-arm64-musl': 14.0.3 - '@next/swc-linux-x64-gnu': 14.0.3 - '@next/swc-linux-x64-musl': 14.0.3 - '@next/swc-win32-arm64-msvc': 14.0.3 - '@next/swc-win32-ia32-msvc': 14.0.3 - '@next/swc-win32-x64-msvc': 14.0.3 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - dev: false - /nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} dev: false - /node-domexception@1.0.0: - resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} - engines: {node: '>=10.5.0'} - dev: false - - /node-fetch@3.3.2: - resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + /node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} dependencies: - data-uri-to-buffer: 4.0.1 - fetch-blob: 3.2.0 - formdata-polyfill: 4.0.10 + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 dev: false - /node-releases@2.0.13: - resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} + /node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + dev: true /normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -4537,16 +4126,6 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} - /normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - dev: true - - /normalize-url@4.5.1: - resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==} - engines: {node: '>=8'} - dev: false - /npm-run-all@4.1.5: resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} engines: {node: '>= 4'} @@ -4569,19 +4148,10 @@ packages: boolbase: 1.0.0 dev: false - /oauth4webapi@2.3.0: - resolution: {integrity: sha512-JGkb5doGrwzVDuHwgrR4nHJayzN4h59VCed6EW8Tql6iHDfZIabCJvg6wtbn5q6pyB2hZruI3b77Nudvq7NmvA==} - dev: false - /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - /object-hash@2.2.0: - resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} - engines: {node: '>= 6'} - dev: false - /object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -4590,6 +4160,11 @@ packages: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} dev: false + /object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + dev: false + /object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -4605,76 +4180,71 @@ packages: object-keys: 1.1.1 dev: false - /object.entries@1.1.6: - resolution: {integrity: sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==} + /object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 dev: false - /object.entries@1.1.7: - resolution: {integrity: sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==} + /object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 dev: false - /object.fromentries@2.0.6: - resolution: {integrity: sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==} + /object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 dev: false - /object.fromentries@2.0.7: - resolution: {integrity: sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==} + /object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - dev: false - - /object.groupby@1.0.1: - resolution: {integrity: sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 - dev: false - - /object.hasown@1.1.2: - resolution: {integrity: sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==} - dependencies: - define-properties: 1.2.0 - es-abstract: 1.22.1 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 dev: false - /object.values@1.1.6: - resolution: {integrity: sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==} + /object.values@1.1.7: + resolution: {integrity: sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 + define-properties: 1.2.1 es-abstract: 1.22.1 dev: false - /object.values@1.1.7: - resolution: {integrity: sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==} + /object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 dev: false + /obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + dev: true + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -4697,16 +4267,14 @@ packages: prelude-ls: 1.2.1 type-check: 0.4.0 - /p-cancelable@1.1.0: - resolution: {integrity: sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==} - engines: {node: '>=6'} - dev: false - - /p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} + /own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} + engines: {node: '>= 0.4'} dependencies: - p-try: 2.2.0 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 dev: false /p-limit@3.1.0: @@ -4715,12 +4283,12 @@ packages: dependencies: yocto-queue: 0.1.0 - /p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} + /p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} dependencies: - p-limit: 2.3.0 - dev: false + yocto-queue: 1.2.2 + dev: true /p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} @@ -4728,20 +4296,15 @@ packages: dependencies: p-limit: 3.1.0 - /p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - dev: false - - /package-json@6.5.0: - resolution: {integrity: sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==} - engines: {node: '>=8'} + /package-json@10.0.1: + resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==} + engines: {node: '>=18'} dependencies: - got: 9.6.0 - registry-auth-token: 4.2.2 - registry-url: 5.1.0 - semver: 6.3.1 - dev: false + ky: 1.14.3 + registry-auth-token: 5.1.1 + registry-url: 6.0.1 + semver: 7.8.5 + dev: true /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} @@ -4749,11 +4312,11 @@ packages: dependencies: callsites: 3.1.0 - /parse-github-url@1.0.2: - resolution: {integrity: sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw==} - engines: {node: '>=0.10.0'} + /parse-github-url@1.0.4: + resolution: {integrity: sha512-CEtCOt55fHmd6DpBc/N7H5NC4vJpcquhzzs9Iw2mRj8bVxo1O5TQI5MXKOMO7+yBOqD+5dKCCRK4Kj1KskZc6Q==} + engines: {node: '>= 0.10'} hasBin: true - dev: false + dev: true /parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} @@ -4807,13 +4370,22 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} - /picocolors@1.0.0: - resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + /pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + dev: true + + /picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + /picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + dev: true + /pidtree@0.3.1: resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} engines: {node: '>=0.10'} @@ -4823,178 +4395,168 @@ packages: /pify@2.3.0: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + dev: true /pify@3.0.0: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} dev: false - /pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} - dev: false - /pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} + dev: true + + /possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + dev: false - /postcss-import@15.1.0(postcss@8.4.31): + /postcss-import@15.1.0(postcss@8.5.26): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} peerDependencies: postcss: ^8.0.0 dependencies: - postcss: 8.4.31 + postcss: 8.5.26 postcss-value-parser: 4.2.0 read-cache: 1.0.0 - resolve: 1.22.3 + resolve: 1.22.8 + dev: true - /postcss-js@4.0.1(postcss@8.4.31): + /postcss-js@4.0.1(postcss@8.5.26): resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: postcss: ^8.4.21 dependencies: camelcase-css: 2.0.1 - postcss: 8.4.31 + postcss: 8.5.26 + dev: true - /postcss-load-config@4.0.1(postcss@8.4.31): - resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==} - engines: {node: '>= 14'} + /postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.19.1): + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} peerDependencies: + jiti: '>=1.21.0' postcss: '>=8.0.9' - ts-node: '>=9.0.0' + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: + jiti: + optional: true postcss: optional: true - ts-node: + tsx: + optional: true + yaml: optional: true dependencies: - lilconfig: 2.1.0 - postcss: 8.4.31 - yaml: 2.3.1 + jiti: 1.21.7 + lilconfig: 3.1.3 + postcss: 8.5.26 + tsx: 4.19.1 + dev: true - /postcss-nested@6.0.1(postcss@8.4.31): - resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + /postcss-nested@6.2.0(postcss@8.5.26): + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 dependencies: - postcss: 8.4.31 - postcss-selector-parser: 6.0.13 + postcss: 8.5.26 + postcss-selector-parser: 6.1.4 + dev: true - /postcss-selector-parser@6.0.13: - resolution: {integrity: sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==} + /postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} engines: {node: '>=4'} dependencies: cssesc: 3.0.0 util-deprecate: 1.0.2 + dev: true /postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + dev: true - /postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + /postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} dependencies: - nanoid: 3.3.6 - picocolors: 1.0.0 - source-map-js: 1.0.2 - - /preact-render-to-string@5.2.3(preact@10.11.3): - resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} - peerDependencies: - preact: '>=10' - dependencies: - preact: 10.11.3 - pretty-format: 3.8.0 - dev: false - - /preact@10.11.3: - resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} - dev: false + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + dev: true /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - /prepend-http@2.0.0: - resolution: {integrity: sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==} - engines: {node: '>=4'} - dev: false - - /prettier-plugin-tailwindcss@0.5.7(@ianvs/prettier-plugin-sort-imports@4.1.1)(prettier@3.1.0): - resolution: {integrity: sha512-4v6uESAgwCni6YF6DwJlRaDjg9Z+al5zM4JfngcazMy4WEf/XkPS5TEQjbD+DZ5iNuG6RrKQLa/HuX2SYzC3kQ==} - engines: {node: '>=14.21.3'} + /prettier-plugin-tailwindcss@0.8.1(@ianvs/prettier-plugin-sort-imports@4.7.1)(prettier@3.9.6): + resolution: {integrity: sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==} + engines: {node: '>=20.19'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' '@prettier/plugin-pug': '*' '@shopify/prettier-plugin-liquid': '*' - '@shufo/prettier-plugin-blade': '*' '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' prettier: ^3.0 prettier-plugin-astro: '*' prettier-plugin-css-order: '*' - prettier-plugin-import-sort: '*' prettier-plugin-jsdoc: '*' prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' prettier-plugin-organize-attributes: '*' prettier-plugin-organize-imports: '*' - prettier-plugin-style-order: '*' + prettier-plugin-sort-imports: '*' prettier-plugin-svelte: '*' - prettier-plugin-twig-melody: '*' peerDependenciesMeta: '@ianvs/prettier-plugin-sort-imports': optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true '@prettier/plugin-pug': optional: true '@shopify/prettier-plugin-liquid': optional: true - '@shufo/prettier-plugin-blade': - optional: true '@trivago/prettier-plugin-sort-imports': optional: true + '@zackad/prettier-plugin-twig': + optional: true prettier-plugin-astro: optional: true prettier-plugin-css-order: optional: true - prettier-plugin-import-sort: - optional: true prettier-plugin-jsdoc: optional: true prettier-plugin-marko: optional: true + prettier-plugin-multiline-arrays: + optional: true prettier-plugin-organize-attributes: optional: true prettier-plugin-organize-imports: optional: true - prettier-plugin-style-order: + prettier-plugin-sort-imports: optional: true prettier-plugin-svelte: optional: true - prettier-plugin-twig-melody: - optional: true dependencies: - '@ianvs/prettier-plugin-sort-imports': 4.1.1(prettier@3.1.0) - prettier: 3.1.0 - dev: false + '@ianvs/prettier-plugin-sort-imports': 4.7.1(prettier@3.9.6) + prettier: 3.9.6 + dev: true - /prettier@3.1.0: - resolution: {integrity: sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==} + /prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} hasBin: true - - /pretty-format@3.8.0: - resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} - dev: false - - /prisma@5.6.0: - resolution: {integrity: sha512-EEaccku4ZGshdr2cthYHhf7iyvCcXqwJDvnoQRAJg5ge2Tzpv0e2BaMCp+CbbDUwoVTzwgOap9Zp+d4jFa2O9A==} - engines: {node: '>=16.13'} - hasBin: true - requiresBuild: true - dependencies: - '@prisma/engines': 5.6.0 + dev: true /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -5004,19 +4566,13 @@ packages: react-is: 16.13.1 dev: false - /proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false - - /pseudomap@1.0.2: - resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} - dev: false + /proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + dev: true - /pump@3.0.0: - resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} - dependencies: - end-of-stream: 1.4.4 - once: 1.4.0 + /proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} dev: false /punycode@2.3.0: @@ -5034,93 +4590,17 @@ packages: ini: 1.3.8 minimist: 1.2.8 strip-json-comments: 2.0.1 - dev: false - - /react-dom@18.2.0(react@18.2.0): - resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} - peerDependencies: - react: ^18.2.0 - dependencies: - loose-envify: 1.4.0 - react: 18.2.0 - scheduler: 0.23.0 - dev: false + dev: true /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false - /react-remove-scroll-bar@2.3.4(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 18.2.38 - react: 18.2.0 - react-style-singleton: 2.2.1(@types/react@18.2.38)(react@18.2.0) - tslib: 2.6.2 - dev: false - - /react-remove-scroll@2.5.5(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 18.2.38 - react: 18.2.0 - react-remove-scroll-bar: 2.3.4(@types/react@18.2.38)(react@18.2.0) - react-style-singleton: 2.2.1(@types/react@18.2.38)(react@18.2.0) - tslib: 2.6.2 - use-callback-ref: 1.3.0(@types/react@18.2.38)(react@18.2.0) - use-sidecar: 1.1.2(@types/react@18.2.38)(react@18.2.0) - dev: false - - /react-ssr-prepass@1.5.0(react@18.2.0): - resolution: {integrity: sha512-yFNHrlVEReVYKsLI5lF05tZoHveA5pGzjFbFJY/3pOqqjGOmMmqx83N4hIjN2n6E1AOa+eQEUxs3CgRnPmT0RQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - dependencies: - react: 18.2.0 - dev: false - - /react-style-singleton@2.2.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 18.2.38 - get-nonce: 1.0.1 - invariant: 2.2.4 - react: 18.2.0 - tslib: 2.6.2 - dev: false - - /react@18.2.0: - resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} - engines: {node: '>=0.10.0'} - dependencies: - loose-envify: 1.4.0 - dev: false - /read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} dependencies: pify: 2.3.0 + dev: true /read-pkg@3.0.0: resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} @@ -5131,16 +4611,6 @@ packages: path-type: 3.0.0 dev: false - /read-yaml-file@1.1.0: - resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} - engines: {node: '>=6'} - dependencies: - graceful-fs: 4.2.11 - js-yaml: 3.14.1 - pify: 4.0.1 - strip-bom: 3.0.0 - dev: false - /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -5156,36 +4626,18 @@ packages: dependencies: picomatch: 2.3.1 - /redis-errors@1.2.0: - resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} - engines: {node: '>=4'} - dev: false - - /redis-parser@3.0.0: - resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} - engines: {node: '>=4'} - dependencies: - redis-errors: 1.2.0 - dev: false - - /reflect.getprototypeof@1.0.4: - resolution: {integrity: sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==} + /reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 - globalthis: 1.0.3 - which-builtin-type: 1.1.3 - dev: false - - /regenerator-runtime@0.13.11: - resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} - dev: false - - /regenerator-runtime@0.14.0: - resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 dev: false /regexp.prototype.flags@1.5.0: @@ -5197,41 +4649,49 @@ packages: functions-have-names: 1.2.3 dev: false - /registry-auth-token@4.2.2: - resolution: {integrity: sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg==} - engines: {node: '>=6.0.0'} + /regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} dependencies: - rc: 1.2.8 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 dev: false - /registry-url@5.1.0: - resolution: {integrity: sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==} - engines: {node: '>=8'} + /registry-auth-token@5.1.1: + resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} + engines: {node: '>=14'} + dependencies: + '@pnpm/npm-conf': 3.0.3 + dev: true + + /registry-url@6.0.1: + resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==} + engines: {node: '>=12'} dependencies: rc: 1.2.8 - dev: false + dev: true /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + /resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + dev: true + /resolve@1.22.2: resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} hasBin: true dependencies: - is-core-module: 2.12.1 + is-core-module: 2.13.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 dev: false - /resolve@1.22.3: - resolution: {integrity: sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==} - hasBin: true - dependencies: - is-core-module: 2.12.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - /resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -5239,33 +4699,56 @@ packages: is-core-module: 2.13.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - dev: false - /resolve@2.0.0-next.4: - resolution: {integrity: sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==} + /resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} hasBin: true dependencies: - is-core-module: 2.12.1 + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.2 + object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 dev: false - /responselike@1.0.2: - resolution: {integrity: sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==} - dependencies: - lowercase-keys: 1.0.1 - dev: false - /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 + /rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + dev: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -5281,13 +4764,14 @@ packages: isarray: 2.0.5 dev: false - /safe-array-concat@1.0.1: - resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} + /safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.1 - has-symbols: 1.0.3 + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 isarray: 2.0.5 dev: false @@ -5295,6 +4779,14 @@ packages: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: false + /safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + dev: false + /safe-regex-test@1.0.0: resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==} dependencies: @@ -5303,23 +4795,25 @@ packages: is-regex: 1.1.4 dev: false + /safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + dev: false + /safe-stable-stringify@2.4.3: resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} engines: {node: '>=10'} dev: false - /scheduler@0.23.0: - resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} - dependencies: - loose-envify: 1.4.0 - dev: false - - /sembear@0.5.2: - resolution: {integrity: sha512-Ij1vCAdFgWABd7zTg50Xw1/p0JgESNxuLlneEAsmBrKishA06ulTTL/SHGmNy2Zud7+rKrHTKNI6moJsn1ppAQ==} + /sembear@0.7.0: + resolution: {integrity: sha512-XyLTEich2D02FODCkfdto3mB9DetWPLuTzr4tvoofe9SvyM27h4nQSbV3+iVcYQz94AFyKtqBv5pcZbj3k2hdA==} dependencies: - '@types/semver': 6.2.3 - semver: 6.3.1 - dev: false + semver: 7.8.5 + dev: true /semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} @@ -5338,13 +4832,41 @@ packages: dependencies: lru-cache: 6.0.0 - /set-function-name@2.0.1: - resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} + /semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + dev: true + + /set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} dependencies: - define-data-property: 1.1.1 + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + dev: false + + /set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 functions-have-names: 1.2.3 - has-property-descriptors: 1.0.0 + has-property-descriptors: 1.0.2 + dev: false + + /set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 dev: false /shebang-command@1.2.0: @@ -5373,6 +4895,35 @@ packages: resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} dev: false + /side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + dev: false + + /side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + dev: false + + /side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + dev: false + /side-channel@1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: @@ -5381,30 +4932,29 @@ packages: object-inspect: 1.12.3 dev: false - /signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - dev: false - - /simple-swizzle@0.2.2: - resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + /side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} dependencies: - is-arrayish: 0.3.2 + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 dev: false + /siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + dev: true + /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - /source-map-js@1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + /source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - - /spawndamnit@2.0.0: - resolution: {integrity: sha512-j4JKEcncSjFlqIwU5L/rp2N5SIPsdxaRsIv678+TZxZ0SRDJTm8JrxJMjE/XuiEZNEir3S8l0Fa3Ke339WI4qA==} - dependencies: - cross-spawn: 5.1.0 - signal-exit: 3.0.7 - dev: false + dev: true /spdx-correct@3.2.0: resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} @@ -5428,38 +4978,56 @@ packages: resolution: {integrity: sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==} dev: false - /sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - dev: false - /stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} dev: false - /standard-as-callback@2.1.0: - resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} - dev: false + /stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + dev: true - /streamsearch@1.1.0: - resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} - engines: {node: '>=10.0.0'} + /std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + dev: true + + /stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 dev: false /string-progressbar@1.0.4: resolution: {integrity: sha512-OpkcFxlFjn7kz5jTWmPGY+FFJVN21lQ9k0fkK0XS5GVvlCgS1stgDNEoMyqnkbZEcVP3Gv6IxqgG7tnD7ChBSw==} dev: false - /string.prototype.matchall@4.0.8: - resolution: {integrity: sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==} + /string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 - has-symbols: 1.0.3 - internal-slot: 1.0.5 - regexp.prototype.flags: 1.5.0 - side-channel: 1.0.4 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + dev: false + + /string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 dev: false /string.prototype.padend@3.1.4: @@ -5471,6 +5039,27 @@ packages: es-abstract: 1.22.1 dev: false + /string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + dependencies: + define-properties: 1.2.1 + es-abstract: 1.22.1 + dev: false + + /string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + dev: false + /string.prototype.trim@1.2.7: resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} engines: {node: '>= 0.4'} @@ -5480,6 +5069,16 @@ packages: es-abstract: 1.22.1 dev: false + /string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + dev: false + /string.prototype.trimend@1.0.6: resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==} dependencies: @@ -5496,6 +5095,15 @@ packages: es-abstract: 1.22.1 dev: false + /string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + dev: false + /string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: @@ -5516,49 +5124,25 @@ packages: /strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} - dev: false + dev: true /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - /styled-jsx@5.1.1(@babel/core@7.22.9)(react@18.2.0): - resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - dependencies: - '@babel/core': 7.22.9 - client-only: 0.0.1 - react: 18.2.0 - dev: false - - /sucrase@3.34.0: - resolution: {integrity: sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==} - engines: {node: '>=8'} + /sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} hasBin: true dependencies: - '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 - glob: 7.1.6 lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.6 + tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 - - /superjson@1.13.3: - resolution: {integrity: sha512-mJiVjfd2vokfDxsQPOwJ/PtanO87LhpYY88ubI5dUB1Ab58Txbyje3+jpm+/83R/fevaq/107NNhtYBLuoTrFg==} - engines: {node: '>=10'} - dependencies: - copy-anything: 3.0.5 - dev: false + dev: true /supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} @@ -5577,49 +5161,37 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - /tailwind-merge@2.0.0: - resolution: {integrity: sha512-WO8qghn9yhsldLSg80au+3/gY9E4hFxIvQ3qOmlpXnqpDKoMruKfi/56BbbMg6fHTQJ9QD3cc79PoWqlaQE4rw==} - dependencies: - '@babel/runtime': 7.23.4 - dev: false - - /tailwindcss-animate@1.0.7(tailwindcss@3.3.5): - resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} - peerDependencies: - tailwindcss: '>=3.0.0 || insiders' - dependencies: - tailwindcss: 3.3.5 - dev: false - - /tailwindcss@3.3.5: - resolution: {integrity: sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==} + /tailwindcss@3.4.19(tsx@4.19.1): + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} hasBin: true dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 - chokidar: 3.5.3 + chokidar: 3.6.0 didyoumean: 1.2.2 dlv: 1.1.3 - fast-glob: 3.3.1 + fast-glob: 3.3.3 glob-parent: 6.0.2 is-glob: 4.0.3 - jiti: 1.19.1 - lilconfig: 2.1.0 - micromatch: 4.0.5 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 normalize-path: 3.0.0 object-hash: 3.0.0 - picocolors: 1.0.0 - postcss: 8.4.31 - postcss-import: 15.1.0(postcss@8.4.31) - postcss-js: 4.0.1(postcss@8.4.31) - postcss-load-config: 4.0.1(postcss@8.4.31) - postcss-nested: 6.0.1(postcss@8.4.31) - postcss-selector-parser: 6.0.13 - resolve: 1.22.3 - sucrase: 3.34.0 + picocolors: 1.1.1 + postcss: 8.5.26 + postcss-import: 15.1.0(postcss@8.5.26) + postcss-js: 4.0.1(postcss@8.5.26) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26)(tsx@4.19.1) + postcss-nested: 6.2.0(postcss@8.5.26) + postcss-selector-parser: 6.1.4 + resolve: 1.22.8 + sucrase: 3.35.1 transitivePeerDependencies: - - ts-node + - tsx + - yaml + dev: true /text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} @@ -5633,25 +5205,35 @@ packages: engines: {node: '>=0.8'} dependencies: thenify: 3.3.1 + dev: true /thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} dependencies: any-promise: 1.3.0 + dev: true - /tiny-typed-emitter@2.1.0: - resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} - dev: false + /tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + dev: true - /to-fast-properties@2.0.0: - resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} - engines: {node: '>=4'} - dev: false + /tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + dev: true - /to-readable-stream@1.0.0: - resolution: {integrity: sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==} - engines: {node: '>=6'} - dev: false + /tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + dev: true + + /tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + dev: true /to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} @@ -5664,23 +5246,28 @@ packages: engines: {node: '>= 14.0.0'} dev: false - /ts-api-utils@1.0.1(typescript@5.3.2): + /ts-api-utils@1.0.1(typescript@5.9.3): resolution: {integrity: sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==} engines: {node: '>=16.13.0'} peerDependencies: typescript: '>=4.2.0' dependencies: - typescript: 5.3.2 + typescript: 5.9.3 /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + dev: true /ts-mixer@6.0.3: resolution: {integrity: sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==} dev: false - /tsconfig-paths@3.14.2: - resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} + /ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + dev: false + + /tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} dependencies: '@types/json5': 0.0.29 json5: 1.0.2 @@ -5688,68 +5275,79 @@ packages: strip-bom: 3.0.0 dev: false - /tslib@2.6.2: - resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + /tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + /tsx@4.19.1: + resolution: {integrity: sha512-0flMz1lh74BR4wOvBjuh9olbnwqCPc35OOlfyzHba0Dc+QNUeWX/Gq2YTbnwcWPO3BMd8fkzRVrHcsR+a7z7rA==} + engines: {node: '>=18.0.0'} + hasBin: true + dependencies: + esbuild: 0.23.1 + get-tsconfig: 4.14.3 + optionalDependencies: + fsevents: 2.3.3 + dev: true - /turbo-darwin-64@1.10.16: - resolution: {integrity: sha512-+Jk91FNcp9e9NCLYlvDDlp2HwEDp14F9N42IoW3dmHI5ZkGSXzalbhVcrx3DOox3QfiNUHxzWg4d7CnVNCuuMg==} + /turbo-darwin-64@1.13.4: + resolution: {integrity: sha512-A0eKd73R7CGnRinTiS7txkMElg+R5rKFp9HV7baDiEL4xTG1FIg/56Vm7A5RVgg8UNgG2qNnrfatJtb+dRmNdw==} cpu: [x64] os: [darwin] requiresBuild: true - dev: false + dev: true optional: true - /turbo-darwin-arm64@1.10.16: - resolution: {integrity: sha512-jqGpFZipIivkRp/i+jnL8npX0VssE6IAVNKtu573LXtssZdV/S+fRGYA16tI46xJGxSAivrZ/IcgZrV6Jk80bw==} + /turbo-darwin-arm64@1.13.4: + resolution: {integrity: sha512-eG769Q0NF6/Vyjsr3mKCnkG/eW6dKMBZk6dxWOdrHfrg6QgfkBUk0WUUujzdtVPiUIvsh4l46vQrNVd9EOtbyA==} cpu: [arm64] os: [darwin] requiresBuild: true - dev: false + dev: true optional: true - /turbo-linux-64@1.10.16: - resolution: {integrity: sha512-PpqEZHwLoizQ6sTUvmImcRmACyRk9EWLXGlqceogPZsJ1jTRK3sfcF9fC2W56zkSIzuLEP07k5kl+ZxJd8JMcg==} + /turbo-linux-64@1.13.4: + resolution: {integrity: sha512-Bq0JphDeNw3XEi+Xb/e4xoKhs1DHN7OoLVUbTIQz+gazYjigVZvtwCvgrZI7eW9Xo1eOXM2zw2u1DGLLUfmGkQ==} cpu: [x64] os: [linux] requiresBuild: true - dev: false + dev: true optional: true - /turbo-linux-arm64@1.10.16: - resolution: {integrity: sha512-TMjFYz8to1QE0fKVXCIvG/4giyfnmqcQIwjdNfJvKjBxn22PpbjeuFuQ5kNXshUTRaTJihFbuuCcb5OYFNx4uw==} + /turbo-linux-arm64@1.13.4: + resolution: {integrity: sha512-BJcXw1DDiHO/okYbaNdcWN6szjXyHWx9d460v6fCHY65G8CyqGU3y2uUTPK89o8lq/b2C8NK0yZD+Vp0f9VoIg==} cpu: [arm64] os: [linux] requiresBuild: true - dev: false + dev: true optional: true - /turbo-windows-64@1.10.16: - resolution: {integrity: sha512-+jsf68krs0N66FfC4/zZvioUap/Tq3sPFumnMV+EBo8jFdqs4yehd6+MxIwYTjSQLIcpH8KoNMB0gQYhJRLZzw==} + /turbo-windows-64@1.13.4: + resolution: {integrity: sha512-OFFhXHOFLN7A78vD/dlVuuSSVEB3s9ZBj18Tm1hk3aW1HTWTuAw0ReN6ZNlVObZUHvGy8d57OAGGxf2bT3etQw==} cpu: [x64] os: [win32] requiresBuild: true - dev: false + dev: true optional: true - /turbo-windows-arm64@1.10.16: - resolution: {integrity: sha512-sKm3hcMM1bl0B3PLG4ifidicOGfoJmOEacM5JtgBkYM48ncMHjkHfFY7HrJHZHUnXM4l05RQTpLFoOl/uIo2HQ==} + /turbo-windows-arm64@1.13.4: + resolution: {integrity: sha512-u5A+VOKHswJJmJ8o8rcilBfU5U3Y1TTAfP9wX8bFh8teYF1ghP0EhtMRLjhtp6RPa+XCxHHVA2CiC3gbh5eg5g==} cpu: [arm64] os: [win32] requiresBuild: true - dev: false + dev: true optional: true - /turbo@1.10.16: - resolution: {integrity: sha512-2CEaK4FIuSZiP83iFa9GqMTQhroW2QryckVqUydmg4tx78baftTOS0O+oDAhvo9r9Nit4xUEtC1RAHoqs6ZEtg==} + /turbo@1.13.4: + resolution: {integrity: sha512-1q7+9UJABuBAHrcC4Sxp5lOqYS5mvxRrwa33wpIyM18hlOCpRD/fTJNxZ0vhbMcJmz15o9kkVm743mPn7p6jpQ==} hasBin: true optionalDependencies: - turbo-darwin-64: 1.10.16 - turbo-darwin-arm64: 1.10.16 - turbo-linux-64: 1.10.16 - turbo-linux-arm64: 1.10.16 - turbo-windows-64: 1.10.16 - turbo-windows-arm64: 1.10.16 - dev: false + turbo-darwin-64: 1.13.4 + turbo-darwin-arm64: 1.13.4 + turbo-linux-64: 1.13.4 + turbo-linux-arm64: 1.13.4 + turbo-windows-64: 1.13.4 + turbo-windows-arm64: 1.13.4 + dev: true /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -5770,6 +5368,15 @@ packages: is-typed-array: 1.1.12 dev: false + /typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + dev: false + /typed-array-byte-length@1.0.0: resolution: {integrity: sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==} engines: {node: '>= 0.4'} @@ -5780,6 +5387,17 @@ packages: is-typed-array: 1.1.12 dev: false + /typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + for-each: 0.3.3 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + dev: false + /typed-array-byte-offset@1.0.0: resolution: {integrity: sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==} engines: {node: '>= 0.4'} @@ -5791,6 +5409,19 @@ packages: is-typed-array: 1.1.12 dev: false + /typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.3 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + dev: false + /typed-array-length@1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} dependencies: @@ -5799,8 +5430,26 @@ packages: is-typed-array: 1.1.12 dev: false - /typescript@5.3.2: - resolution: {integrity: sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==} + /typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + dev: false + + /typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true @@ -5813,48 +5462,33 @@ packages: which-boxed-primitive: 1.0.2 dev: false - /undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - - /undici@5.22.1: - resolution: {integrity: sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==} - engines: {node: '>=14.0'} - dependencies: - busboy: 1.6.0 - dev: false - - /undici@5.27.2: - resolution: {integrity: sha512-iS857PdOEy/y3wlM3yRp+6SNQQ6xU0mmZcwRSriqk+et/cwWAtwmIGf6WkoDN2EK/AMdCO/dfXzIwi+rFMrjjQ==} - engines: {node: '>=14.0'} + /unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} dependencies: - '@fastify/busboy': 2.1.0 + call-bound: 1.0.4 + has-bigints: 1.0.2 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 dev: false - /universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} - dev: false + /undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - /update-browserslist-db@1.0.11(browserslist@4.21.9): - resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - dependencies: - browserslist: 4.21.9 - escalade: 3.1.1 - picocolors: 1.0.0 + /undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} dev: false - /update-browserslist-db@1.0.13(browserslist@4.22.1): - resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + /update-browserslist-db@1.3.2(browserslist@4.28.8): + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' dependencies: - browserslist: 4.22.1 - escalade: 3.1.1 - picocolors: 1.0.0 + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 dev: true /uri-js@4.4.1: @@ -5862,44 +5496,6 @@ packages: dependencies: punycode: 2.3.0 - /url-parse-lax@3.0.0: - resolution: {integrity: sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==} - engines: {node: '>=4'} - dependencies: - prepend-http: 2.0.0 - dev: false - - /use-callback-ref@1.3.0(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 18.2.38 - react: 18.2.0 - tslib: 2.6.2 - dev: false - - /use-sidecar@1.1.2(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} - engines: {node: '>=10'} - peerDependencies: - '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@types/react': 18.2.38 - detect-node-es: 1.1.0 - react: 18.2.0 - tslib: 2.6.2 - dev: false - /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -5910,24 +5506,124 @@ packages: spdx-expression-parse: 3.0.1 dev: false - /validate-npm-package-name@3.0.0: - resolution: {integrity: sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==} - dependencies: - builtins: 1.0.3 - dev: false + /validate-npm-package-name@6.0.2: + resolution: {integrity: sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==} + engines: {node: ^18.17.0 || >=20.5.0} + dev: true - /watchpack@2.4.0: - resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} - engines: {node: '>=10.13.0'} + /vite@8.2.2(@types/node@22.5.4)(tsx@4.19.1): + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - dev: false + '@types/node': 22.5.4 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.26 + rolldown: 1.2.7 + tinyglobby: 0.2.17 + tsx: 4.19.1 + optionalDependencies: + fsevents: 2.3.3 + dev: true - /web-streams-polyfill@3.2.1: - resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} - engines: {node: '>= 8'} - dev: false + /vitest@4.1.0(@types/node@22.5.4)(vite@8.2.2): + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.0 + '@vitest/browser-preview': 4.1.0 + '@vitest/browser-webdriverio': 4.1.0 + '@vitest/ui': 4.1.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/node': 22.5.4 + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@8.2.2) + '@vitest/pretty-format': 4.1.0 + '@vitest/runner': 4.1.0 + '@vitest/snapshot': 4.1.0 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.2(@types/node@22.5.4)(tsx@4.19.1) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - msw + dev: true /which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} @@ -5939,31 +5635,44 @@ packages: is-symbol: 1.0.4 dev: false - /which-builtin-type@1.1.3: - resolution: {integrity: sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==} + /which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} dependencies: - function.prototype.name: 1.1.5 - has-tostringtag: 1.0.0 + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + dev: false + + /which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 is-async-function: 2.0.0 - is-date-object: 1.0.5 - is-finalizationregistry: 1.0.2 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 is-generator-function: 1.0.10 - is-regex: 1.1.4 - is-weakref: 1.0.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 isarray: 2.0.5 - which-boxed-primitive: 1.0.2 - which-collection: 1.0.1 - which-typed-array: 1.1.11 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 dev: false - /which-collection@1.0.1: - resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==} + /which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} dependencies: - is-map: 2.0.2 - is-set: 2.0.2 - is-weakmap: 2.0.1 - is-weakset: 2.0.2 + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 dev: false /which-typed-array@1.1.11: @@ -5977,6 +5686,19 @@ packages: has-tostringtag: 1.0.0 dev: false + /which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + dev: false + /which@1.3.1: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true @@ -5991,63 +5713,59 @@ packages: dependencies: isexe: 2.0.0 - /winston-daily-rotate-file@4.7.1(winston@3.11.0): - resolution: {integrity: sha512-7LGPiYGBPNyGHLn9z33i96zx/bd71pjBn9tqQzO3I4Tayv94WPmBNwKC7CO1wPHdP9uvu+Md/1nr6VSH9h0iaA==} + /why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + dev: true + + /winston-daily-rotate-file@5.0.0(winston@3.19.0): + resolution: {integrity: sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==} engines: {node: '>=8'} peerDependencies: winston: ^3 dependencies: file-stream-rotator: 0.6.1 - object-hash: 2.2.0 + object-hash: 3.0.0 triple-beam: 1.4.1 - winston: 3.11.0 - winston-transport: 4.5.0 + winston: 3.19.0 + winston-transport: 4.9.0 dev: false - /winston-transport@4.5.0: - resolution: {integrity: sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==} - engines: {node: '>= 6.4.0'} + /winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} dependencies: - logform: 2.5.1 + logform: 2.7.0 readable-stream: 3.6.2 triple-beam: 1.4.1 dev: false - /winston@3.11.0: - resolution: {integrity: sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g==} + /winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} engines: {node: '>= 12.0.0'} dependencies: '@colors/colors': 1.6.0 - '@dabh/diagnostics': 2.0.3 + '@dabh/diagnostics': 2.0.8 async: 3.2.4 is-stream: 2.0.1 - logform: 2.5.1 + logform: 2.7.0 one-time: 1.0.0 readable-stream: 3.6.2 safe-stable-stringify: 2.4.3 stack-trace: 0.0.10 triple-beam: 1.4.1 - winston-transport: 4.5.0 + winston-transport: 4.9.0 dev: false /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - /ws@8.13.0: - resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: false - - /ws@8.14.2: - resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==} + /ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -6059,25 +5777,20 @@ packages: optional: true dev: false - /yallist@2.1.2: - resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} - dev: false - - /yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - dev: false - /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - /yaml@2.3.1: - resolution: {integrity: sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==} - engines: {node: '>= 14'} + /yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + dev: true /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - /zod@3.22.4: - resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} - dev: false + /yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + dev: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7775d25e9..227a54611 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,5 @@ packages: - apps/dashboard - apps/bot - - packages/api - - packages/auth - packages/db - packages/config/* diff --git a/scripts/common.mjs b/scripts/common.mjs new file mode 100644 index 000000000..64dfbc056 --- /dev/null +++ b/scripts/common.mjs @@ -0,0 +1,523 @@ +import { execSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +export const rootDir = path.resolve(__dirname, '..'); +export const logsDir = path.join(rootDir, 'logs'); + +/** Path to the dedicated YouTube OAuth token file (gitignored). */ +const youtubeOAuthPath = path.join(rootDir, '.youtube-oauth.json'); + +export function loadEnv() { + const envPath = path.join(rootDir, '.env'); + if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf-8'); + for (const rawLine of envContent.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)$/); + if (match) { + const key = match[1]; + let val = match[2].trim(); + + if (val.startsWith('"')) { + const quoteEnd = val.indexOf('"', 1); + val = quoteEnd !== -1 ? val.substring(1, quoteEnd) : val.substring(1); + } else if (val.startsWith("'")) { + const quoteEnd = val.indexOf("'", 1); + val = quoteEnd !== -1 ? val.substring(1, quoteEnd) : val.substring(1); + } else { + const hashIndex = val.indexOf('#'); + if (hashIndex !== -1) { + val = val.substring(0, hashIndex).trim(); + } + } + + if (!process.env[key]) { + process.env[key] = val; + } + } + } + } +} + +export function extractPortFromUrl(urlStr, defaultPort) { + if (!urlStr) return defaultPort; + try { + const parsed = new URL(urlStr); + if (parsed.port) return parseInt(parsed.port, 10); + return parsed.protocol === 'https:' ? 443 : 80; + } catch { + const match = urlStr.match(/:(\d+)/); + if (match) return parseInt(match[1], 10); + return defaultPort; + } +} + +export function freePort(port) { + if (!port) return; + const isWindows = process.platform === 'win32'; + try { + if (isWindows) { + const stdout = execSync(`netstat -ano | findstr :${port}`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'] + }); + const lines = stdout.split(/\r?\n/); + const pidsToKill = new Set(); + for (const line of lines) { + if (line.includes('LISTENING')) { + const parts = line.trim().split(/\s+/); + const pid = parts[parts.length - 1]; + if (pid && pid !== '0' && /^\d+$/.test(pid)) { + pidsToKill.add(pid); + } + } + } + for (const pid of pidsToKill) { + try { + execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); + } catch {} + } + } else { + execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { + stdio: 'ignore' + }); + } + } catch {} +} + +/** + * Kills a process and all of its spawned child processes recursively. + */ +export function killProcessTree(proc) { + if (!proc || !proc.pid) return; + try { + if (process.platform === 'win32') { + execSync(`taskkill /PID ${proc.pid} /T /F`, { stdio: 'ignore' }); + } else { + proc.kill('SIGTERM'); + } + } catch {} +} + +/** + * Checks whether a TCP port is actively open and listening. + */ +export function isPortInUse(port, host = '127.0.0.1', timeoutMs = 1500) { + return new Promise(resolve => { + import('node:net').then(({ default: net }) => { + const socket = new net.Socket(); + socket.setTimeout(timeoutMs); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => { + socket.destroy(); + resolve(false); + }); + socket.on('timeout', () => { + socket.destroy(); + resolve(false); + }); + socket.connect(port, host); + }); + }); +} + +/** + * Ensures the SQLite database location is ready. + * + * The new node:sqlite layer (BotDatabase) auto-creates its file and schema on + * first start, so this only validates that the target directory exists and is + * writable. No Prisma sync is performed anymore. + */ +export function ensureSqliteDatabase() { + const dbPath = process.env.DISCORD_DB_PATH + ? process.env.DISCORD_DB_PATH + : path.join(rootDir, 'data', 'bot.sqlite'); + const dir = path.dirname(dbPath); + try { + fs.mkdirSync(dir, { recursive: true }); + const isReady = fs.existsSync(dbPath); + return { + status: isReady + ? `READY (${dbPath})` + : `WILL CREATE ON FIRST START (${dbPath})`, + process: null + }; + } catch (err) { + return { + status: `ERROR (unable to ensure ${dbPath}: ${err.message})`, + process: null + }; + } +} + + +/** + * Polls a TCP port until a connection succeeds or timeout expires. + * Used to ensure Lavalink has booted and is listening before spawning the bot. + */ +export function waitForPort(port, host = '127.0.0.1', timeoutMs = 25000) { + return new Promise(resolve => { + const start = Date.now(); + const check = () => { + if (Date.now() - start > timeoutMs) { + return resolve(false); + } + import('node:net').then(({ default: net }) => { + const socket = new net.Socket(); + socket.setTimeout(1000); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => { + socket.destroy(); + setTimeout(check, 500); + }); + socket.on('timeout', () => { + socket.destroy(); + setTimeout(check, 500); + }); + socket.connect(port, host); + }); + }; + check(); + }); +} + +/** + * Validates that Java >= 17 is installed and accessible on PATH. + * Lavalink v4 requires Java 17+; Java 21 LTS is recommended. + * Returns { ok: true, version } on success, { ok: false, error } on failure. + */ +export function checkJavaVersion() { + try { + const output = execSync('java -version 2>&1', { + encoding: 'utf-8', + stdio: 'pipe' + }); + // java -version prints to stderr; execSync captures both via 2>&1 + const match = output.match(/version\s+"?(\d+)(?:\.(\d+))?/); + if (!match) { + return { ok: false, error: 'Could not parse Java version output.' }; + } + // Java 9+ uses single-component versioning (e.g. "17", "21") + // Java 8 uses "1.8" format + const major = parseInt(match[1], 10); + const actualMajor = major === 1 ? parseInt(match[2] || '0', 10) : major; + if (actualMajor < 17) { + return { + ok: false, + error: `Java ${actualMajor} detected. Lavalink v4 requires Java 17 or higher (Java 21 LTS recommended). Please upgrade: https://www.azul.com/downloads/?package=jdk#zulu` + }; + } + return { ok: true, version: actualMajor }; + } catch { + return { + ok: false, + error: + 'Java not found on PATH. Lavalink requires Java 17+ to run. Install Java 21 LTS: https://www.azul.com/downloads/?package=jdk#zulu' + }; + } +} + +// --------------------------------------------------------------------------- +// YouTube OAuth Token Persistence (Item 1C) +// Tokens are stored in .youtube-oauth.json (gitignored) with atomic writes. +// The .env file is NEVER modified at runtime. +// --------------------------------------------------------------------------- + +/** + * Loads a previously saved YouTube OAuth refresh token from .youtube-oauth.json + * into process.env.YOUTUBE_REFRESH_TOKEN. Call this after loadEnv() and before + * getLavalinkKeyStatus() / spawning Lavalink. + * + * If the .env already has a valid token, the file token is only used as a + * fallback (env takes precedence so users can override via .env if desired). + */ +export function loadYouTubeToken() { + const isLavalinkEnabled = + (process.env.LAVA_ENABLED || process.env.ENABLE_LAVALINK)?.toLowerCase() === + 'true'; + if (!isLavalinkEnabled) { + return; + } + + // If a valid token is already set (e.g. from .env), keep it + const existing = process.env.YOUTUBE_REFRESH_TOKEN?.trim(); + if (existing && existing.startsWith('1/')) { + return; + } + + if (!fs.existsSync(youtubeOAuthPath)) return; + + try { + const raw = fs.readFileSync(youtubeOAuthPath, 'utf-8'); + const data = JSON.parse(raw); + if ( + data.refreshToken && + typeof data.refreshToken === 'string' && + data.refreshToken.startsWith('1/') + ) { + process.env.YOUTUBE_REFRESH_TOKEN = data.refreshToken; + console.log( + `\x1b[1;32mโœ… [YOUTUBE TOKEN LOADED]\x1b[0m Loaded YouTube OAuth refresh token from .youtube-oauth.json (saved ${data.savedAt || 'unknown date'})\n` + ); + } + } catch { + // Corrupted file โ€” ignore, Lavalink will re-prompt device flow + } +} + +export function clearYouTubeRefreshToken() { + delete process.env.YOUTUBE_REFRESH_TOKEN; + // Also remove the persisted file so a stale token isn't reloaded on next launch + try { + if (fs.existsSync(youtubeOAuthPath)) { + fs.unlinkSync(youtubeOAuthPath); + } + } catch {} +} + +/** + * Builds JVM arguments array for launching Lavalink with deterministic + * System Properties (-D) for YouTube OAuth, remote cipher, and Spotify credentials. + */ +export function getLavalinkJavaArgs() { + const args = []; + + const ytToken = process.env.YOUTUBE_REFRESH_TOKEN?.trim() || ''; + const hasValidToken = ytToken.startsWith('1/'); + + args.push(`-DYOUTUBE_REFRESH_TOKEN=${hasValidToken ? ytToken : ''}`); + args.push(`-DYOUTUBE_SKIP_INIT=${hasValidToken ? 'true' : 'false'}`); + + const cipherUrl = + process.env.YOUTUBE_CIPHER_URL?.trim() || 'https://cipher.kikkia.dev/'; + args.push(`-DYOUTUBE_CIPHER_URL=${cipherUrl}`); + + const cipherPassword = process.env.YOUTUBE_CIPHER_PASSWORD?.trim() || ''; + args.push(`-DYOUTUBE_CIPHER_PASSWORD=${cipherPassword}`); + + const spotifyId = process.env.SPOTIFY_CLIENT_ID?.trim() || ''; + const spotifySecret = process.env.SPOTIFY_CLIENT_SECRET?.trim() || ''; + args.push(`-DSPOTIFY_CLIENT_ID=${spotifyId}`); + args.push(`-DSPOTIFY_CLIENT_SECRET=${spotifySecret}`); + + args.push('-jar', 'Lavalink.jar'); + + return args; +} + +/** + * Checks for configured music API keys in process.env. + * Returns boolean flags for youtube, spotify, and hasAny. + * SoundCloud uses Lavalink's built-in free source โ€” no keys needed. + */ +export function getLavalinkKeyStatus() { + const ytToken = process.env.YOUTUBE_REFRESH_TOKEN?.trim(); + const validYtToken = ytToken && ytToken.startsWith('1/') ? ytToken : null; + + // If a token exists in env but doesn't start with 1/, auto-clear it in memory + if (ytToken && !validYtToken) { + clearYouTubeRefreshToken(); + } + + const youtube = !!(process.env.YOUTUBE_API_KEY || validYtToken); + const spotify = !!( + process.env.SPOTIFY_CLIENT_ID && process.env.SPOTIFY_CLIENT_SECRET + ); + const hasAny = youtube || spotify; + + return { + youtube, + spotify, + hasAny + }; +} + +export function extractYouTubeRefreshToken(line) { + // Matches 1/ or 1// starting after whitespace, colon, equals, quote, or parenthesis + const match = line.match(/(?:^|[\s:='"(])(1\/[a-zA-Z0-9_\-.~/]+)/); + if (!match) return null; + + // Trim trailing quotes, braces, commas, parentheses, dots, or whitespace + let token = match[1].replace(/[}"',.;!)\s]+$/, ''); + + if (token.length >= 20 && token.startsWith('1/')) { + return token; + } + return null; +} + +/** + * Saves a YouTube OAuth refresh token to .youtube-oauth.json using atomic + * write (write to .tmp then rename) and sets it in process.env. + * The .env file is NEVER modified. + */ +export function saveYouTubeRefreshToken(token) { + if (!token || !token.startsWith('1/')) return; + + // Deduplicate: if the exact token is already active in memory, do nothing + if (process.env.YOUTUBE_REFRESH_TOKEN === token) { + return; + } + + process.env.YOUTUBE_REFRESH_TOKEN = token; + + // Persist to dedicated file with atomic write + const data = JSON.stringify( + { refreshToken: token, savedAt: new Date().toISOString() }, + null, + 2 + ); + const tmpPath = youtubeOAuthPath + '.tmp'; + try { + fs.writeFileSync(tmpPath, data, 'utf-8'); + fs.renameSync(tmpPath, youtubeOAuthPath); + } catch (err) { + // If atomic rename fails (e.g. cross-device), try direct write + try { + fs.writeFileSync(youtubeOAuthPath, data, 'utf-8'); + } catch {} + } + + const successBanner = `\n\x1b[1;32m====================================================================\x1b[0m\n\x1b[1;32mโœ… [YOUTUBE REFRESH TOKEN CAPTURED & SAVED]\x1b[0m\n\x1b[1;36m Token:\x1b[0m ${token}\n\x1b[1;32m Persisted to .youtube-oauth.json (survives restart).\x1b[0m\n\x1b[1;32m====================================================================\x1b[0m\n\n`; + process.stdout.write(successBanner); +} + +export function isAuthInfo(line) { + const lower = line.toLowerCase(); + + if ( + lower.includes('exception') || + lower.includes('caused by:') || + lower.includes('unsatisfieddependencyexception') || + lower.includes('beancreationexception') + ) { + return false; + } + + return ( + line.includes('google.com/device') || + line.includes('https://www.google.com/device') || + line.includes('To authenticate') || + (lower.includes('device') && + lower.includes('code') && + lower.includes('enter')) || + (lower.includes('user_code') && lower.includes('verification_url')) + ); +} + +// --------------------------------------------------------------------------- +// Error Detection & Console Surfacing (Item 1D) +// --------------------------------------------------------------------------- + +/** ANSI color codes keyed by log prefix */ +const prefixColors = { + BOT: '\x1b[1;31m', // red + 'BOT-ERR': '\x1b[1;31m', // red + DASHBOARD: '\x1b[1;35m', // magenta + 'DASHBOARD-ERR': '\x1b[1;35m', // magenta + LAVALINK: '\x1b[1;33m', // yellow + 'LAVALINK-ERR': '\x1b[1;33m', // yellow + SYSTEM: '\x1b[1;36m' // cyan +}; +const RESET = '\x1b[0m'; + +/** + * Returns true if a log line represents an error that should be surfaced + * in the terminal console. Stack trace continuation lines (e.g. " at ...") + * are excluded to keep console output compact โ€” full stacks stay in log files. + */ +function isErrorLine(line) { + const trimmed = line.trim(); + + // Skip stack trace continuation lines โ€” they belong in logs only + if (trimmed.startsWith('at ') || trimmed.startsWith('Caused by:')) + return false; + + // Skip common false positives in source code references + if ( + trimmed.includes('error.cause') || + trimmed.includes('errorFormatter') || + trimmed.includes('error_handler') + ) + return false; + + // Match actual error indicators + return ( + /\bError\b/.test(trimmed) || + /\bERR\b/.test(trimmed) || + /\bFATAL\b/i.test(trimmed) || + /\bException\b/.test(trimmed) || + /exited with code/i.test(trimmed) || + (/\bfailed\b/i.test(trimmed) && + /\b(to|load|resolve|connect|start|build|compile)\b/i.test(trimmed)) || + /\bcrash/i.test(trimmed) || + /ECONNREFUSED|ENOTFOUND|EACCES|EPERM/i.test(trimmed) + ); +} + +/** + * Returns true if a line represents a warning worth surfacing. + */ +function isWarnLine(line) { + const trimmed = line.trim(); + if (trimmed.startsWith('at ') || trimmed.startsWith('Caused by:')) + return false; + return /\bWARN\b/.test(trimmed); +} + +export function createLogWriter(fileStream, combinedStream) { + return function writeLog(prefix, data) { + const timestamp = new Date().toISOString(); + const lines = data.toString().split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) continue; + + // Auto-capture YouTube OAuth refresh token output from youtube-plugin + const token = extractYouTubeRefreshToken(line); + if (token) { + saveYouTubeRefreshToken(token); + } + + if (line.includes('Invalid status code for oauth2 token fetch: 400')) { + clearYouTubeRefreshToken(); + const errBanner = `\n\x1b[1;31m====================================================================\x1b[0m\n\x1b[1;31mโš ๏ธ [INVALID YOUTUBE REFRESH TOKEN DETECTED]\x1b[0m\n\x1b[1;33m Google rejected the stored YouTube refresh token (HTTP 400 Bad Request).\x1b[0m\n\x1b[1;33m The invalid token has been cleared from .youtube-oauth.json.\x1b[0m\n\x1b[1;36m Lavalink will now prompt for a fresh YouTube device authorization code.\x1b[0m\n\x1b[1;31m====================================================================\x1b[0m\n\n`; + process.stdout.write(errBanner); + } + + if (isAuthInfo(line)) { + // DO NOT write sensitive auth info to disk log files! + // Display directly in custom console output for the user: + const authBanner = `\n\x1b[1;33m====================================================================\x1b[0m\n\x1b[1;32m๐Ÿ”‘ [YOUTUBE OAUTH DEVICE AUTHENTICATION REQUIRED]\x1b[0m\n\x1b[1;36m Source:\x1b[0m [${prefix}]\n\x1b[1;37m ${line.trim()}\x1b[0m\n\x1b[1;33m====================================================================\x1b[0m\n\n`; + process.stdout.write(authBanner); + } else { + const entry = `[${timestamp}] [${prefix}] ${line}\n`; + fileStream.write(entry); + combinedStream.write(entry); + + // Surface errors and warnings to the terminal console (Item 1D) + const color = prefixColors[prefix] || '\x1b[1;37m'; + if (isErrorLine(line)) { + process.stderr.write( + `${color}โš  [${prefix}]${RESET} \x1b[31m${line.trim()}${RESET}\n` + ); + } else if (isWarnLine(line)) { + process.stderr.write( + `${color}โšก [${prefix}]${RESET} \x1b[33m${line.trim()}${RESET}\n` + ); + } + } + } + }; +} diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 000000000..186f6ea58 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,234 @@ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + rootDir, + logsDir, + loadEnv, + loadYouTubeToken, + freePort, + isPortInUse, + ensureSqliteDatabase, + waitForPort, + checkJavaVersion, + getLavalinkKeyStatus, + getLavalinkJavaArgs, + createLogWriter, + killProcessTree +} from './common.mjs'; + +loadEnv(); + +const isLavalinkEnabled = + (process.env.LAVA_ENABLED || process.env.ENABLE_LAVALINK)?.toLowerCase() === + 'true'; + +if (isLavalinkEnabled) { + loadYouTubeToken(); +} + +const keyStatus = getLavalinkKeyStatus(); + +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} + +const botLogFile = path.join(logsDir, 'bot.log'); +const lavalinkLogFile = path.join(logsDir, 'lavalink.log'); +const combinedLogFile = path.join(logsDir, 'combined.log'); + +const botStream = fs.createWriteStream(botLogFile, { flags: 'w' }); +const lavalinkStream = fs.createWriteStream(lavalinkLogFile, { flags: 'w' }); +const combinedStream = fs.createWriteStream(combinedLogFile, { flags: 'w' }); + +const writeBotLog = createLogWriter(botStream, combinedStream); +const writeLavalinkLog = createLogWriter(lavalinkStream, combinedStream); + +// Single unified HTTP port for the embedded dashboard + OAuth2 callback server +// (HELIX alignment). NEXTAUTH_URL / NEXTAUTH_INTERNAL_URL auto-resolve from it. +let port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; +if (isNaN(port) || port <= 0) port = 3000; + +const lavaHost = process.env.LAVA_HOST || '0.0.0.0'; +const lavaPort = parseInt(process.env.LAVA_PORT || '2333', 10); +const isLavaExternal = process.env.LAVA_EXTERNAL?.toLowerCase() === 'true'; + +// Free up the unified HTTP port and optional Lavalink port before launching +freePort(port); +if (!isLavaExternal && isLavalinkEnabled) { + freePort(lavaPort); +} + +// 1. Ensure SQLite Database is initialized (auto-created on first start) +const { status: sqliteStatus } = ensureSqliteDatabase(); + +let lavalinkStatus = 'DISABLED'; +let lavalinkProcess = null; + +// 2. Dynamic Service Check & Launch for Lavalink (only if LAVA_ENABLED=true) +const hostToCheck = lavaHost === '0.0.0.0' ? '127.0.0.1' : lavaHost; + +if (!isLavalinkEnabled) { + lavalinkStatus = 'DISABLED'; + writeLavalinkLog( + 'SYSTEM', + 'Lavalink audio engine launch SKIPPED: Audio engine is currently disabled.' + ); +} else { + const isAlreadyRunning = await isPortInUse(lavaPort, hostToCheck, 1500); + + if (isAlreadyRunning) { + lavalinkStatus = `RUNNING (Connected to existing instance on ${hostToCheck}:${lavaPort})`; + writeLavalinkLog( + 'SYSTEM', + `Existing Lavalink server detected running on ${hostToCheck}:${lavaPort}. Connected directly.` + ); + console.log( + `\n\x1b[1;32mโœ… [LAVALINK ACTIVE]\x1b[0m Connected to existing Lavalink instance on port ${lavaPort}\n` + ); + } else if (isLavaExternal) { + lavalinkStatus = `EXTERNAL (${lavaHost}:${lavaPort})`; + writeLavalinkLog( + 'SYSTEM', + `LAVA_EXTERNAL=true set. Waiting for external Lavalink server at ${lavaHost}:${lavaPort}...` + ); + const isReady = await waitForPort(lavaPort, hostToCheck, 25000); + if (isReady) { + console.log( + `\x1b[1;32mโœ… [EXTERNAL LAVALINK READY]\x1b[0m Connected to external Lavalink on port ${lavaPort}\n` + ); + } + } else if (!keyStatus.hasAny) { + lavalinkStatus = 'DISABLED (No API Keys Configured)'; + writeLavalinkLog( + 'SYSTEM', + 'Lavalink server launch SKIPPED: No music API keys (YouTube or Spotify) provided in .env.' + ); + console.log( + '\n\x1b[1;33mโš ๏ธ [LAVALINK DISABLED]\x1b[0m No music API keys configured in .env (YouTube or Spotify). Internal Lavalink server skipped.\n' + ); + } else { + const jarPath = path.join(rootDir, 'Lavalink.jar'); + if (fs.existsSync(jarPath)) { + lavalinkStatus = 'RUNNING (Internal)'; + writeLavalinkLog( + 'SYSTEM', + `Launching internal Lavalink server from ${jarPath}...` + ); + const javaCheck = checkJavaVersion(); + if (!javaCheck.ok) { + console.error( + `\n\x1b[1;31mโš ๏ธ [JAVA VERSION ERROR]\x1b[0m\n${javaCheck.error}\n` + ); + lavalinkStatus = 'ERROR (Java missing or too old)'; + } else { + if (javaCheck.version < 21) { + console.warn( + `\n\x1b[1;33mโš ๏ธ [JAVA VERSION WARNING]\x1b[0m Java ${javaCheck.version} detected. Java 21 LTS is recommended for best stability.\n` + ); + } + const javaArgs = getLavalinkJavaArgs(); + lavalinkProcess = spawn('java', javaArgs, { + cwd: rootDir, + env: { ...process.env } + }); + lavalinkProcess.stdout.on('data', data => + writeLavalinkLog('LAVALINK', data) + ); + lavalinkProcess.stderr.on('data', data => + writeLavalinkLog('LAVALINK-ERR', data) + ); + console.log( + '\nโณ Waiting for Lavalink audio engine to become ready...' + ); + const isReady = await waitForPort(lavaPort, hostToCheck, 25000); + if (isReady) { + console.log( + `\x1b[1;32mโœ… [LAVALINK READY]\x1b[0m Audio engine listening on port ${lavaPort}\n` + ); + } + } + } else { + lavalinkStatus = `EXTERNAL/DOCKER (${lavaHost}:${lavaPort})`; + writeLavalinkLog( + 'SYSTEM', + 'Lavalink.jar not found in root directory. Assuming external or Docker Lavalink instance.' + ); + } + } +} + +// 3. Launch the SINGLE Master-Bot process (Discord client + embedded dashboard +// + OAuth2 callback server) bound to the unified PORT. +const botProcess = spawn(`pnpm --filter @master-bot/bot dev`, { + cwd: rootDir, + shell: true, + env: { + ...process.env, + PORT: String(port) + } +}); +botProcess.stdout.on('data', data => writeBotLog('BOT', data)); +botProcess.stderr.on('data', data => writeBotLog('BOT-ERR', data)); + +const oauthNote = isLavalinkEnabled + ? ` +==================================================================== + ๐Ÿ”‘ NOTE: YouTube OAuth / Device Auth prompts are output DIRECTLY + to this console. Tokens are persisted in .youtube-oauth.json upon authorization. +====================================================================` + : ` +====================================================================`; + +const baseUrl = `http://localhost:${port}`; +const dashboardPublicUrl = process.env.NEXTAUTH_URL?.trim(); +const dashboardUrlDisplay = dashboardPublicUrl + ? `${baseUrl} | Public: ${dashboardPublicUrl}` + : baseUrl; + +const activeServices = [ + ` โ€ข ๐Ÿค– Master-Bot: RUNNING (Discord client + embedded dashboard, Port: ${port}) โ””โ”€ Log: logs/bot.log`, + ` โ€ข ๐ŸŒ Web Dashboard: ${dashboardUrlDisplay}\n โ””โ”€ /dashboard ยท OAuth2: /api/auth/callback/discord`, + ` โ€ข ๐Ÿ’พ SQLite Database: ${sqliteStatus}`, + ` โ€ข โšก In-Memory Queue: ACTIVE (Zero external dependency)` +]; + +if (isLavalinkEnabled && !lavalinkStatus.startsWith('DISABLED')) { + const cipherInfo = + process.env.YOUTUBE_CIPHER_URL?.trim() || 'https://cipher.kikkia.dev/'; + activeServices.push( + ` โ€ข ๐ŸŽต Lavalink Audio: ${lavalinkStatus}\n โ””โ”€ Cipher: ${cipherInfo}\n โ””โ”€ Log: logs/lavalink.log` + ); +} + +// Display Clean Terminal Status Banner +console.log(` +==================================================================== + ๐Ÿค– MASTER-BOT UNIFIED CONSOLE (DEV) +==================================================================== + Execution Mode: DEVELOPMENT + Unified Port: ${port}${isLavalinkEnabled ? ` | Lavalink: ${lavaPort}` : ''} + + Active Services: +${activeServices.join('\n')} + + Combined System Log: logs/combined.log + Live Owner Web Logs: ${baseUrl}/dashboard${oauthNote} +`); + +function cleanup() { + console.log('\n๐Ÿ›‘ Shutting down Master-Bot dev services...'); + try { + if (lavalinkProcess) killProcessTree(lavalinkProcess); + killProcessTree(botProcess); + } catch {} + botStream.end(); + lavalinkStream.end(); + combinedStream.end(); + process.exit(0); +} + +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); +process.on('SIGHUP', cleanup); +process.on('exit', cleanup); \ No newline at end of file diff --git a/scripts/start.mjs b/scripts/start.mjs new file mode 100644 index 000000000..10d37d206 --- /dev/null +++ b/scripts/start.mjs @@ -0,0 +1,252 @@ +import { spawn, execSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + rootDir, + logsDir, + loadEnv, + loadYouTubeToken, + freePort, + isPortInUse, + ensureSqliteDatabase, + waitForPort, + checkJavaVersion, + getLavalinkKeyStatus, + getLavalinkJavaArgs, + createLogWriter, + killProcessTree +} from './common.mjs'; + +loadEnv(); + +const botDist = path.join(rootDir, 'apps', 'bot', 'dist', 'index.js'); + +if (!fs.existsSync(botDist)) { + console.log( + '\n๐Ÿ“ฆ Production build not detected. Building packages before launch...' + ); + execSync('pnpm build', { cwd: rootDir, stdio: 'inherit' }); + console.log('โœ… Production build completed successfully.\n'); +} + +const isLavalinkEnabled = + (process.env.LAVA_ENABLED || process.env.ENABLE_LAVALINK)?.toLowerCase() === + 'true'; + +if (isLavalinkEnabled) { + loadYouTubeToken(); +} + +const keyStatus = getLavalinkKeyStatus(); + +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} + +const botLogFile = path.join(logsDir, 'bot.log'); +const lavalinkLogFile = path.join(logsDir, 'lavalink.log'); +const combinedLogFile = path.join(logsDir, 'combined.log'); + +const botStream = fs.createWriteStream(botLogFile, { flags: 'w' }); +const lavalinkStream = fs.createWriteStream(lavalinkLogFile, { flags: 'w' }); +const combinedStream = fs.createWriteStream(combinedLogFile, { flags: 'w' }); + +const writeBotLog = createLogWriter(botStream, combinedStream); +const writeLavalinkLog = createLogWriter(lavalinkStream, combinedStream); + +// Single unified HTTP port for the embedded dashboard + OAuth2 callback server +// (HELIX alignment). NEXTAUTH_URL / NEXTAUTH_INTERNAL_URL auto-resolve from it. +let port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; +if (isNaN(port) || port <= 0) port = 3000; + +const lavaHost = process.env.LAVA_HOST || '0.0.0.0'; +const lavaPort = parseInt(process.env.LAVA_PORT || '2333', 10); +const isLavaExternal = process.env.LAVA_EXTERNAL?.toLowerCase() === 'true'; + +// Free up the unified HTTP port and optional Lavalink port before launching +freePort(port); +if (!isLavaExternal && isLavalinkEnabled) { + freePort(lavaPort); +} + +// 1. Ensure SQLite Database is initialized (auto-created on first start) +const { status: sqliteStatus } = ensureSqliteDatabase(); + +let lavalinkStatus = 'DISABLED'; +let lavalinkProcess = null; + +// 2. Dynamic Service Check & Launch for Lavalink (only if LAVA_ENABLED=true) +const hostToCheck = lavaHost === '0.0.0.0' ? '127.0.0.1' : lavaHost; + +if (!isLavalinkEnabled) { + lavalinkStatus = 'DISABLED'; + writeLavalinkLog( + 'SYSTEM', + 'Lavalink audio engine launch SKIPPED: Audio engine is currently disabled.' + ); +} else { + const isAlreadyRunning = await isPortInUse(lavaPort, hostToCheck, 1500); + + if (isAlreadyRunning) { + lavalinkStatus = `RUNNING (Connected to existing instance on ${hostToCheck}:${lavaPort})`; + writeLavalinkLog( + 'SYSTEM', + `Existing Lavalink server detected running on ${hostToCheck}:${lavaPort}. Connected directly.` + ); + console.log( + `\n\x1b[1;32mโœ… [LAVALINK ACTIVE]\x1b[0m Connected to existing Lavalink instance on port ${lavaPort}\n` + ); + } else if (isLavaExternal) { + lavalinkStatus = `EXTERNAL (${lavaHost}:${lavaPort})`; + writeLavalinkLog( + 'SYSTEM', + `LAVA_EXTERNAL=true set. Waiting for external Lavalink server at ${lavaHost}:${lavaPort}...` + ); + const isReady = await waitForPort(lavaPort, hostToCheck, 25000); + if (isReady) { + writeLavalinkLog( + 'SYSTEM', + `Connected to external Lavalink server at ${lavaHost}:${lavaPort}.` + ); + } else { + writeLavalinkLog( + 'SYSTEM', + `Warning: External Lavalink server at ${lavaHost}:${lavaPort} did not respond within 25s.` + ); + } + } else if (!keyStatus.hasAny) { + lavalinkStatus = 'DISABLED (No API Keys Configured)'; + writeLavalinkLog( + 'SYSTEM', + 'Lavalink server launch SKIPPED: No music API keys (YouTube or Spotify) provided in .env.' + ); + console.log( + '\n\x1b[1;33mโš ๏ธ [LAVALINK DISABLED]\x1b[0m No music API keys configured in .env (YouTube or Spotify). Internal Lavalink server skipped.\n' + ); + } else { + const lavalinkJar = path.join(rootDir, 'Lavalink.jar'); + if (fs.existsSync(lavalinkJar)) { + const appYml = path.join(rootDir, 'application.yml'); + if (!fs.existsSync(appYml)) { + lavalinkStatus = 'FAILED (application.yml missing)'; + writeLavalinkLog( + 'SYSTEM', + 'Lavalink.jar found but application.yml is missing. Copy application.yml.example to application.yml.' + ); + } else { + lavalinkStatus = `RUNNING (Port: ${lavaPort})`; + writeLavalinkLog( + 'SYSTEM', + `Spawning internal Lavalink instance via Lavalink.jar on port ${lavaPort}...` + ); + const javaCheck = checkJavaVersion(); + if (!javaCheck.ok) { + console.warn( + `\n\x1b[1;33mโš ๏ธ [JAVA VERSION WARNING]\x1b[0m Java ${javaCheck.version} detected. Java 21 LTS is recommended for best stability.\n` + ); + } + const javaArgs = getLavalinkJavaArgs(); + lavalinkProcess = spawn('java', javaArgs, { + cwd: rootDir, + env: { ...process.env } + }); + lavalinkProcess.stdout.on('data', data => + writeLavalinkLog('LAVALINK', data) + ); + lavalinkProcess.stderr.on('data', data => + writeLavalinkLog('LAVALINK-ERR', data) + ); + console.log( + '\nโณ Waiting for Lavalink audio engine to become ready...' + ); + const isReady = await waitForPort(lavaPort, hostToCheck, 25000); + if (isReady) { + console.log( + `\x1b[1;32mโœ… [LAVALINK READY]\x1b[0m Audio engine listening on port ${lavaPort}\n` + ); + } + } + } else { + lavalinkStatus = `EXTERNAL/DOCKER (${lavaHost}:${lavaPort})`; + writeLavalinkLog( + 'SYSTEM', + 'Lavalink.jar not found in root directory. Assuming external or Docker Lavalink instance.' + ); + } + } +} + +// 3. Launch the SINGLE Master-Bot process (Discord client + embedded dashboard +// + OAuth2 callback server) bound to the unified PORT. +const botProcess = spawn(`pnpm --filter @master-bot/bot start`, { + cwd: rootDir, + shell: true, + env: { + ...process.env, + PORT: String(port) + } +}); +botProcess.stdout.on('data', data => writeBotLog('BOT', data)); +botProcess.stderr.on('data', data => writeBotLog('BOT-ERR', data)); + +const oauthNote = isLavalinkEnabled + ? ` +==================================================================== + ๐Ÿ”‘ NOTE: YouTube OAuth / Device Auth prompts are output DIRECTLY + to this console. Tokens are persisted in .youtube-oauth.json upon authorization. +====================================================================` + : ` +====================================================================`; + +const baseUrl = `http://localhost:${port}`; +const dashboardPublicUrl = process.env.NEXTAUTH_URL?.trim(); +const dashboardUrlDisplay = dashboardPublicUrl + ? `${baseUrl} | Public: ${dashboardPublicUrl}` + : baseUrl; + +const activeServices = [ + ` โ€ข ๐Ÿค– Master-Bot: RUNNING (Discord client + embedded dashboard, Port: ${port}) โ””โ”€ Log: logs/bot.log`, + ` โ€ข ๐ŸŒ Web Dashboard: ${dashboardUrlDisplay}\n โ””โ”€ /dashboard ยท OAuth2: /api/auth/callback/discord`, + ` โ€ข ๐Ÿ’พ SQLite Database: ${sqliteStatus}`, + ` โ€ข โšก In-Memory Queue: ACTIVE (Zero external dependency)` +]; + +if (isLavalinkEnabled && !lavalinkStatus.startsWith('DISABLED')) { + const cipherInfo = + process.env.YOUTUBE_CIPHER_URL?.trim() || 'https://cipher.kikkia.dev/'; + activeServices.push( + ` โ€ข ๐ŸŽต Lavalink Audio: ${lavalinkStatus}\n โ””โ”€ Cipher: ${cipherInfo}\n โ””โ”€ Log: logs/lavalink.log` + ); +} + +// Display Clean Terminal Status Banner +console.log(` +==================================================================== + ๐Ÿค– MASTER-BOT UNIFIED CONSOLE (PRODUCTION) +==================================================================== + Execution Mode: PRODUCTION + Unified Port: ${port}${isLavalinkEnabled ? ` | Lavalink: ${lavaPort}` : ''} + + Active Services: +${activeServices.join('\n')} + + Combined System Log: logs/combined.log + Live Owner Web Logs: ${baseUrl}/dashboard${oauthNote} +`); + +function cleanup() { + console.log('\n๐Ÿ›‘ Shutting down Master-Bot production services...'); + try { + if (lavalinkProcess) killProcessTree(lavalinkProcess); + killProcessTree(botProcess); + } catch {} + botStream.end(); + lavalinkStream.end(); + combinedStream.end(); + process.exit(0); +} + +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); +process.on('SIGHUP', cleanup); +process.on('exit', cleanup); \ No newline at end of file diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 000000000..80c7200fb --- /dev/null +++ b/tests/README.md @@ -0,0 +1,37 @@ +# Master-Bot Vitest Test Suite + +Automated testing harness for Master-Bot across bot commands, database models, tRPC procedures, and dashboard utilities. + +--- + +## ๐Ÿƒ Running Tests + +```bash +# Run all unit and integration tests once +pnpm test + +# Run tests in watch mode during development +pnpm run test:watch + +# Run tests with code coverage report +pnpm run test:coverage + +# Verify test type safety +pnpm run test:types +``` + +--- + +## ๐Ÿ“‚ Test Suite Structure + +```text +tests/ +โ”œโ”€โ”€ unit/ # Isolated unit tests for functions, schemas & helpers +โ”‚ โ”œโ”€โ”€ config.test.ts # Configuration & feature flag validations +โ”‚ โ””โ”€โ”€ env.test.ts # Environment variable parsing tests +โ”œโ”€โ”€ integration/ # End-to-end service and API integration tests +โ”‚ โ””โ”€โ”€ (expanded in Phase 2) +โ”œโ”€โ”€ helpers/ # Mock generators & test harness utilities +โ”œโ”€โ”€ fixtures/ # Static sample payloads & JSON fixtures +โ””โ”€โ”€ README.md # Test suite documentation +``` diff --git a/tests/integration/dashboard-api.test.ts b/tests/integration/dashboard-api.test.ts new file mode 100644 index 000000000..8c197dc19 --- /dev/null +++ b/tests/integration/dashboard-api.test.ts @@ -0,0 +1,120 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import http, { createServer, type Server } from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + routeDashboardRequest, + setDashboardContext +} from '@master-bot/dashboard'; +import type { DashboardContext } from '@master-bot/dashboard'; +import { setDatabasePath, BotDatabase } from '@master-bot/db'; + +function mockContext(): DashboardContext { + return { + getBotState: () => ({ + isReady: true, + gatewayLatency: 42, + guilds: [] + }), + sendChannelMessage: async () => true, + getGatewayLatency: () => 42, + isOwner: (userId?: string) => userId === 'owner-123' + }; +} + +function request( + url: string +): Promise<{ + status: number; + headers: http.IncomingHttpHeaders; + body: string; +}> { + return new Promise((resolve, reject) => { + const req = http.get(url, res => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks.map(c => new Uint8Array(c))).toString( + 'utf8' + ) + }) + ); + }); + req.on('error', reject); + }); +} + +describe('Dashboard HTTP API Integration', () => { + let server: Server; + let baseUrl: string; + let tempDir: string; + + beforeAll(async () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'master-bot-dash-')); + setDatabasePath(path.join(tempDir, 'dashboard.sqlite')); + setDashboardContext(mockContext()); + + server = createServer((req, res) => { + routeDashboardRequest(req, res, `http://${req.headers.host}`) + .then(handled => { + if (!handled) { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('Not Found'); + } + }) + .catch(() => { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Internal Server Error'); + }); + }); + + await new Promise(resolve => server.listen(0, resolve)); + const addr = server.address(); + if (!addr || typeof addr === 'string') { + throw new Error('Failed to bind test server'); + } + baseUrl = `http://127.0.0.1:${addr.port}`; + }); + + afterAll(async () => { + await new Promise(resolve => server.close(() => resolve())); + BotDatabase.resetInstance(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('serves the dashboard shell at the root', async () => { + const { status, headers } = await request(`${baseUrl}/`); + expect(status).toBe(200); + expect(String(headers['content-type'])).toContain('text/html'); + }); + + it('redirects to the Discord authorization endpoint with a client id', async () => { + const { status, headers } = await request(`${baseUrl}/invite?client_id=12345`); + expect(status).toBe(302); + expect(String(headers.location)).toContain('discord.com/oauth2/authorize'); + }); + + it('renders a setup page when no client id is configured', async () => { + delete process.env.DISCORD_CLIENT_ID; + delete process.env.CLIENT_ID; + delete process.env.DISCORD_APP_ID; + delete process.env.APPLICATION_ID; + delete process.env.APP_ID; + const { status, headers } = await request(`${baseUrl}/invite`); + expect(status).toBe(200); + expect(String(headers['content-type'])).toContain('text/html'); + }); + + it('serves stats as JSON without requiring a session', async () => { + const { status, headers, body } = await request(`${baseUrl}/api/dashboard/stats`); + expect(status).toBe(200); + expect(String(headers['content-type'])).toContain('application/json'); + const parsed = JSON.parse(body); + expect(parsed).toHaveProperty('bot'); + expect(parsed).toHaveProperty('database'); + }); +}); \ No newline at end of file diff --git a/tests/unit/api/routers.test.ts b/tests/unit/api/routers.test.ts new file mode 100644 index 000000000..440a02505 --- /dev/null +++ b/tests/unit/api/routers.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { setDatabasePath, BotDatabase } from '@master-bot/db'; +import { dataService } from '@master-bot/dataService'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +describe('Data Service API', () => { + let dbPath: string; + + beforeEach(() => { + dbPath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'master-bot-ds-')), + 'test.sqlite' + ); + setDatabasePath(dbPath); + BotDatabase.resetInstance(); + }); + + afterEach(() => { + BotDatabase.resetInstance(); + setDatabasePath(''); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + }); + + it('exposes all core data service namespaces', () => { + for (const ns of [ + 'user', + 'playlist', + 'song', + 'guild', + 'hub', + 'twitch', + 'command', + 'tickets', + 'welcome', + 'reminder' + ]) { + expect(dataService).toHaveProperty(ns); + } + }); + + it('creates and retrieves playlists for a user', async () => { + const { user } = await dataService.user.create({ + id: 'user-1', + name: 'Tester' + }); + const { playlist } = await dataService.playlist.create({ + userId: user.id, + name: 'Vibes' + }); + expect(playlist.name).toBe('Vibes'); + + const { playlists } = await dataService.playlist.getAll({ + userId: user.id + }); + expect(playlists.length).toBe(1); + }); + + it('avoids cross-user playlist leakage', async () => { + const { user } = await dataService.user.create({ + id: 'user-1', + name: 'Tester' + }); + await dataService.playlist.create({ userId: user.id, name: 'Private' }); + const { playlists } = await dataService.playlist.getAll({ + userId: 'user-2' + }); + expect(playlists.length).toBe(0); + }); + + it('round-trips a twitch notification through the twitch namespace', async () => { + await dataService.twitch.create({ + userId: 'twitch-1', + userImage: 'https://example.com/logo.png', + channelId: 'channel-1', + sendTo: ['channel-1', 'channel-2'] + }); + + const { notification } = await dataService.twitch.findUserById({ + id: 'twitch-1' + }); + expect(notification).not.toBeNull(); + expect(notification?.channelIds).toEqual(['channel-1']); + }); + + it('returns disabled commands for a guild', async () => { + const { disabledCommands } = await dataService.command.getDisabledCommands({ + guildId: 'guild-1' + }); + expect(disabledCommands).toEqual([]); + }); + + it('returns null for unknown guilds', async () => { + const { guild } = await dataService.guild.getGuild({ id: 'guild-1' }); + expect(guild).toBeNull(); + }); +}); \ No newline at end of file diff --git a/tests/unit/auth/auth-config.test.ts b/tests/unit/auth/auth-config.test.ts new file mode 100644 index 000000000..b71ce3a9c --- /dev/null +++ b/tests/unit/auth/auth-config.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + createSessionToken, + getDashboardUrl, + getNextAuthConfig, + normalizeCallbackBaseUrl, + verifySessionToken +} from '../../../apps/dashboard/src/auth/config.js'; + +const originalEnv = process.env; + +describe('Dashboard Auth Configuration Module', () => { + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('normalizes a bare callback base url', () => { + expect(normalizeCallbackBaseUrl('http://localhost:3000')).toBe( + 'http://localhost:3000' + ); + }); + + it('strips the callback path from a full callback url', () => { + expect( + normalizeCallbackBaseUrl('http://localhost:3000/api/auth/callback/discord') + ).toBe('http://localhost:3000'); + }); + + it('auto-resolves the dashboard url to localhost when unset', () => { + delete process.env.NEXTAUTH_URL; + delete process.env.DISCORD_CALLBACK_URL; + process.env.PORT = '3000'; + expect(getDashboardUrl()).toBe('http://localhost:3000'); + }); + + it('builds a nextauth-compatible config from environment', () => { + process.env.DISCORD_CLIENT_ID = 'client-123'; + process.env.DISCORD_CLIENT_SECRET = 'secret-456'; + const config = getNextAuthConfig(); + expect(config.clientId).toBe('client-123'); + expect(config.clientSecret).toBe('secret-456'); + expect(config.secret.length).toBeGreaterThan(0); + }); + + it('round-trips session tokens through sign/verify', () => { + const token = createSessionToken({ + id: 'user-1', + name: 'Test Admin' + }); + expect(token).toContain('.'); + + const payload = verifySessionToken(token); + expect(payload).not.toBeNull(); + expect(payload.id).toBe('user-1'); + expect(payload.name).toBe('Test Admin'); + }); + + it('rejects tampered session tokens', () => { + const token = createSessionToken({ id: 'user-1', name: 'Test Admin' }); + const tampered = token.slice(0, -4) + 'AAAA'; + expect(verifySessionToken(tampered)).toBeNull(); + }); + + it('rejects malformed or missing tokens', () => { + expect(verifySessionToken(undefined)).toBeNull(); + expect(verifySessionToken('not-a-token')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/tests/unit/bot/constants.test.ts b/tests/unit/bot/constants.test.ts new file mode 100644 index 000000000..fe090839b --- /dev/null +++ b/tests/unit/bot/constants.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { rootDir, srcDir } from '../../../apps/bot/src/lib/constants'; +import { existsSync } from 'fs'; + +describe('Bot Directory Constants', () => { + it('defines rootDir pointing to valid apps/bot root directory', () => { + expect(rootDir).toBeDefined(); + expect(existsSync(rootDir)).toBe(true); + }); + + it('defines srcDir pointing to valid apps/bot/src directory', () => { + expect(srcDir).toBeDefined(); + expect(existsSync(srcDir)).toBe(true); + }); +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts new file mode 100644 index 000000000..df0ae3232 --- /dev/null +++ b/tests/unit/config.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; + +describe('Master-Bot Configuration & Workspace Environment', () => { + it('should validate default environment variables exist in runtime', () => { + expect(process.env).toBeDefined(); + }); + + it('should verify supported audio filter names', () => { + const supportedFilters = [ + 'bassboost', + 'nightcore', + 'karaoke', + 'vaporwave', + '8d', + 'tremolo' + ]; + expect(supportedFilters).toHaveLength(6); + expect(supportedFilters).toContain('bassboost'); + expect(supportedFilters).toContain('nightcore'); + }); + + it('should verify 18 audit log event trigger types', () => { + const auditLogEvents = [ + 'channelCreate', + 'channelDelete', + 'channelUpdate', + 'guildMemberAdd', + 'guildMemberRemove', + 'guildMemberUpdate', + 'guildBanAdd', + 'guildBanRemove', + 'messageDelete', + 'messageDeleteBulk', + 'messageUpdate', + 'roleCreate', + 'roleDelete', + 'roleUpdate', + 'voiceStateUpdate', + 'emojiCreate', + 'emojiDelete', + 'emojiUpdate' + ]; + expect(auditLogEvents).toHaveLength(18); + }); +}); diff --git a/tests/unit/db/prisma.test.ts b/tests/unit/db/prisma.test.ts new file mode 100644 index 000000000..f36c682cb --- /dev/null +++ b/tests/unit/db/prisma.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { setDatabasePath, BotDatabase } from '@master-bot/db'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +describe('BotDatabase Module', () => { + let db: BotDatabase; + let dbPath: string; + + beforeEach(() => { + dbPath = path.join( + fs.mkdtempSync(path.join(os.tmpdir(), 'master-bot-db-')), + 'test.sqlite' + ); + setDatabasePath(dbPath); + BotDatabase.resetInstance(); + db = BotDatabase.getInstance(); + }); + + afterEach(() => { + BotDatabase.resetInstance(); + setDatabasePath(''); + fs.rmSync(path.dirname(dbPath), { recursive: true, force: true }); + }); + + it('exposes a singleton instance and a reset hook', () => { + expect(BotDatabase.getInstance()).toBe(db); + BotDatabase.resetInstance(); + expect(BotDatabase.getInstance()).not.toBe(db); + }); + + it('round-trips a user through upsert and getters', () => { + const user = db.upsertUser('discord-1', 'Tester'); + expect(user.discordId).toBe('discord-1'); + expect(db.getUserByDiscordId('discord-1')).toEqual(user); + expect(db.getUserById(user.id)).toEqual(user); + }); + + it('creates, retrieves and deletes playlists', () => { + db.upsertUser('discord-1', 'Tester'); + const user = db.getUserByDiscordId('discord-1')!; + + const playlist = db.createPlaylist(user.id, 'Vibes'); + expect(playlist.name).toBe('Vibes'); + expect(db.getPlaylist(user.id, 'Vibes')?.name).toBe('Vibes'); + expect(db.getAllPlaylists(user.id).length).toBe(1); + + const songs = db.createSongs([ + { + length: 180, + track: 'Song A', + identifier: 'song-a', + author: 'Artist', + isStream: false, + position: 1, + title: 'Song A', + uri: 'https://example.com/song-a', + isSeekable: true, + sourceName: 'https', + thumbnail: '', + added: Date.now(), + playlistId: playlist.id + } + ]); + expect(songs.count).toBe(1); + + const withSongs = db.getPlaylist(user.id, 'Vibes'); + expect(withSongs?.songs.length).toBe(1); + expect(withSongs?.songs[0]?.title).toBe('Song A'); + + db.deletePlaylist(user.id, 'Vibes'); + expect(db.getPlaylist(user.id, 'Vibes')).toBeNull(); + }); + + it('round-trips twitch notifications with channel lists', () => { + db.upsertTwitchNotification('twitch-1', 'logo.png', ['c-1', 'c-2']); + const note = db.getTwitchNotification('twitch-1'); + expect(note).not.toBeNull(); + expect(note!.channelIds).toBe('["c-1","c-2"]'); + expect(db.getAllTwitchNotifications().length).toBe(1); + + const removed = db.deleteTwitchNotification('twitch-1'); + expect(removed).not.toBeNull(); + expect(db.getTwitchNotification('twitch-1')).toBeNull(); + }); + + it('creates and resolves temp channels for a guild', () => { + db.upsertGuild('guild-1', 'owner-1', 'Guild 1'); + db.createTempChannel('guild-1', 'owner-1', 'channel-1'); + const found = db.getTempChannel('guild-1', 'owner-1'); + expect(found).not.toBeNull(); + expect(found!.id).toBe('channel-1'); + + db.deleteTempChannelByChannelId('channel-1'); + expect(db.getTempChannel('guild-1', 'owner-1')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/tests/unit/env.test.ts b/tests/unit/env.test.ts new file mode 100644 index 000000000..807882bd2 --- /dev/null +++ b/tests/unit/env.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { getPort } from '../../apps/bot/src/env'; + +describe('Environment Variable Utilities', () => { + it('should handle boolean flags properly', () => { + const parseBool = ( + val: string | undefined, + defaultVal = false + ): boolean => { + if (val === undefined) return defaultVal; + return val.toLowerCase() === 'true' || val === '1'; + }; + + expect(parseBool('true')).toBe(true); + expect(parseBool('TRUE')).toBe(true); + expect(parseBool('1')).toBe(true); + expect(parseBool('false')).toBe(false); + expect(parseBool(undefined, true)).toBe(true); + expect(parseBool(undefined, false)).toBe(false); + }); + + it('defaults the unified HTTP port to 3000', () => { + delete process.env.PORT; + expect(getPort()).toBe(3000); + }); + + it('resolves the unified HTTP port from a single PORT key', () => { + process.env.PORT = '4321'; + expect(getPort()).toBe(4321); + delete process.env.PORT; + expect(getPort()).toBe(3000); + }); + + it('falls back to 3000 for a non-numeric PORT', () => { + process.env.PORT = 'invalid'; + expect(getPort()).toBe(3000); + delete process.env.PORT; + }); + + it('ignores legacy separate dashboard/bot port keys', () => { + delete process.env.PORT; + process.env.DASHBOARD_PORT = '7000'; + process.env.BOT_PORT = '7001'; + expect(getPort()).toBe(3000); + delete process.env.DASHBOARD_PORT; + delete process.env.BOT_PORT; + }); +}); \ No newline at end of file diff --git a/tests/unit/scripts/common.test.ts b/tests/unit/scripts/common.test.ts new file mode 100644 index 000000000..526f1c1b9 --- /dev/null +++ b/tests/unit/scripts/common.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { + extractPortFromUrl, + rootDir, + logsDir +} from '../../../scripts/common.mjs'; +import { existsSync } from 'fs'; + +describe('Common Lifecycle Script Helpers', () => { + it('resolves valid rootDir and logsDir paths', () => { + expect(rootDir).toBeDefined(); + expect(existsSync(rootDir)).toBe(true); + expect(logsDir).toBeDefined(); + }); + + it('extracts port correctly from various URL formats', () => { + expect(extractPortFromUrl('http://localhost:3000', 8080)).toBe(3000); + expect(extractPortFromUrl('http://127.0.0.1:4000/api', 8080)).toBe(4000); + expect(extractPortFromUrl('https://example.com', 8080)).toBe(443); + expect(extractPortFromUrl('http://example.com', 8080)).toBe(80); + expect(extractPortFromUrl('', 8080)).toBe(8080); + expect(extractPortFromUrl(null, 3000)).toBe(3000); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 6d6afdee8..129bb6e46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 000000000..d290a860c --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node", "vitest/globals"], + "allowJs": true, + "noEmit": true, + "baseUrl": ".", + "paths": { + "~/*": ["apps/dashboard/src/*"], + "@master-bot/db": ["packages/db/index.ts"], + "@master-bot/dashboard": ["apps/dashboard/src/index.ts"], + "@master-bot/dataService": ["apps/bot/src/dataService.ts"] + } + }, + "include": ["tests/**/*.ts"] +} \ No newline at end of file diff --git a/turbo.json b/turbo.json index 920452d34..d9d3080b6 100644 --- a/turbo.json +++ b/turbo.json @@ -2,20 +2,12 @@ "$schema": "https://turborepo.org/schema.json", "globalDependencies": ["**/.env", "tsconfig.json"], "pipeline": { - "db:generate": { - "inputs": ["prisma/schema.prisma"], - "cache": false - }, - "db:push": { - "inputs": ["prisma/schema.prisma"], - "cache": false - }, "dev": { "persistent": true, "cache": false }, "build": { - "dependsOn": ["^build", "^db:generate"], + "dependsOn": ["^build"], "outputs": [".next/**", "dist/**"] }, "lint": {}, @@ -27,22 +19,43 @@ "cache": false }, "type-check": { - "dependsOn": ["^db:generate"], "cache": false } }, "globalEnv": [ "CI", - "DATABASE_URL", "DISCORD_TOKEN", "DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET", - "NEXT_PUBLIC_INVITE_URL", + "DISCORD_OWNER_ID", + "OWNER_ID", "NEXTAUTH_SECRET", "NEXTAUTH_URL", + "DISCORD_CALLBACK_URL", + "DISCORD_DB_PATH", "NODE_ENV", - "SKIP_ENV_VALIDATION", - "VERCEL", - "VERCEL_URL" + "PORT", + "LAVA_HOST", + "LAVA_PASS", + "LAVA_PORT", + "LAVA_SECURE", + "LAVA_EXTERNAL", + "LAVA_ENABLED", + "GIFS_ENABLED", + "TWITCH_ENABLED", + "NEWS_ENABLED", + "IGDB_ENABLED", + "YOUTUBE_API_KEY", + "YOUTUBE_REFRESH_TOKEN", + "YOUTUBE_CIPHER_URL", + "YOUTUBE_CIPHER_PASSWORD", + "SPOTIFY_CLIENT_ID", + "SPOTIFY_CLIENT_SECRET", + "TWITCH_CLIENT_ID", + "TWITCH_CLIENT_SECRET", + "KLIPY_API", + "NEWS_API", + "GENIUS_API", + "PORT" ] } diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 000000000..71af767d5 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,26 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +const rootDir = import.meta.dirname; + +export default defineConfig({ + resolve: { + alias: { + '~': path.resolve(rootDir, 'apps/dashboard/src'), + '@master-bot/db': path.resolve(rootDir, 'packages/db/index.ts'), + '@master-bot/dashboard': path.resolve(rootDir, 'apps/dashboard/src/index.ts'), + '@master-bot/dataService': path.resolve(rootDir, 'apps/bot/src/dataService.ts') + } + }, + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + server: { + deps: { + inline: ['@master-bot/db'], + external: ['node:sqlite'] + } + } + } +}); \ No newline at end of file diff --git a/wiki/API-Keys.md b/wiki/API-Keys.md new file mode 100644 index 000000000..4e7f4a679 --- /dev/null +++ b/wiki/API-Keys.md @@ -0,0 +1,45 @@ +# ๐Ÿ”‘ API Keys & Integrations Guide + +Step-by-step guide to acquiring credentials from developer portals. + +--- + +## 1. Discord Bot Token & OAuth2 Credentials +- **Portal**: [Discord Developer Portal](https://discord.com/developers/applications) +- **Intents**: Enable `Message Content Intent` and `Server Members Intent`. +- **Variables**: `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`. + +--- + +## 2. Spotify Developer API +- **Portal**: [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) +- **Variables**: `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`. +- **Purpose**: Enables metadata search and ISRC resolution for Spotify tracks and playlists in Lavalink. + +--- + +## 3. Twitch & IGDB Developer API +- **Portal**: [Twitch Developer Console](https://dev.twitch.tv/console) +- **Variables**: `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET`, `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET`. +- **Purpose**: Live Twitch stream alerts and IGDB video game database queries (`/game-search`). + +--- + +## 4. Klipy (GIF Search Engine) +- **Portal**: [Klipy Developers](https://klipy.com/developers) +- **Variable**: `KLIPY_API`. +- **Purpose**: Powers `/gif` reaction commands. + +--- + +## 5. NewsAPI (Global Headlines) +- **Portal**: [NewsAPI.org](https://newsapi.org/) +- **Variable**: `NEWS_API`. +- **Purpose**: Powers `/world-news` headlines search across countries and categories. + +--- + +## 6. Genius API (Lyrics) +- **Portal**: [Genius API Clients](https://genius.com/api-clients/new) +- **Variable**: `GENIUS_API`. +- **Purpose**: Song lyrics search (`/lyrics`). diff --git a/wiki/Commands-Moderation.md b/wiki/Commands-Moderation.md new file mode 100644 index 000000000..ab157e44b --- /dev/null +++ b/wiki/Commands-Moderation.md @@ -0,0 +1,15 @@ +# ๐Ÿ”จ Moderation Commands Reference + +Complete reference for guild moderation commands with role hierarchy checks. + +--- + +## Moderation Commands + +| Command | Description | Required Permissions | Usage | +| :--- | :--- | :--- | :--- | +| `/ban` | Ban a user from the server | `BanMembers` | `/ban user: @user reason: "Spamming"` | +| `/kick` | Kick a user from the server | `KickMembers` | `/kick user: @user reason: "Breaking rules"` | +| `/timeout` | Timeout (mute) a user for a duration | `ModerateMembers` | `/timeout user: @user duration: 10m reason: "Toxic"` | +| `/slowmode` | Set channel message slowmode rate limit | `ManageChannels` | `/slowmode seconds: 5` | +| `/purge` | Bulk delete a specified number of messages | `ManageMessages` | `/purge amount: 25` | diff --git a/wiki/Commands-Music.md b/wiki/Commands-Music.md new file mode 100644 index 000000000..90317723b --- /dev/null +++ b/wiki/Commands-Music.md @@ -0,0 +1,48 @@ +# ๐ŸŽต Music & Playlist Commands Reference + +Complete reference for music playback and playlist management commands. + +--- + +## Playback & Queue Commands + +| Command | Description | Usage | +| :--- | :--- | :--- | +| `/play` | Play a song, playlist, or YouTube/Spotify query | `/play query: darude sandstorm` | +| `/pause` | Pause active song playback | `/pause` | +| `/resume` | Resume paused playback | `/resume` | +| `/skip` | Skip current song | `/skip` | +| `/skipto` | Skip to a specific track number in queue | `/skipto track: 5` | +| `/skipall` | Clear queue and stop playback | `/skipall` | +| `/queue` | View current song queue | `/queue` | +| `/shuffle` | Shuffle tracks in queue | `/shuffle` | +| `/volume` | Adjust volume (1-200%) | `/volume percent: 80` | +| `/loop` | Loop current song or queue | `/loop mode: song` | +| `/seek` | Seek to a specific timestamp | `/seek timestamp: 1:30` | +| `/lyrics` | Fetch lyrics for current or searched track | `/lyrics song: bohemian rhapsody` | +| `/music-trivia` | Start an interactive music trivia game | `/music-trivia` | +| `/leave` | Disconnect bot from voice channel | `/leave` | + +--- + +## Audio DSP Filter Commands + +| Command | Description | Usage | +| :--- | :--- | :--- | +| `/bassboost` | Apply bassboost audio filter | `/bassboost level: high` | +| `/nightcore` | Apply nightcore tempo/pitch filter | `/nightcore` | +| `/vaporwave` | Apply vaporwave tempo/pitch filter | `/vaporwave` | +| `/karaoke` | Apply vocal suppression filter | `/karaoke` | + +--- + +## Playlist Management Commands + +| Command | Description | Usage | +| :--- | :--- | :--- | +| `/create-playlist` | Create a new custom playlist | `/create-playlist name: "Favorites"` | +| `/save-to-playlist` | Save track to playlist | `/save-to-playlist name: "Favorites" query: "..."` | +| `/remove-from-playlist` | Remove track from playlist | `/remove-from-playlist name: "Favorites" index: 1` | +| `/my-playlists` | View all custom playlists | `/my-playlists` | +| `/display-playlist` | View tracks in playlist | `/display-playlist name: "Favorites"` | +| `/delete-playlist` | Delete a custom playlist | `/delete-playlist name: "Favorites"` | diff --git a/wiki/Commands-Server-Settings.md b/wiki/Commands-Server-Settings.md new file mode 100644 index 000000000..a3fd3e12c --- /dev/null +++ b/wiki/Commands-Server-Settings.md @@ -0,0 +1,16 @@ +# โš™๏ธ Server Configuration (`/set`) Manual + +Complete reference for guild configuration subcommands under `/set`. + +--- + +## `/set` Subcommand Catalog + +| Subcommand | Description | Usage | +| :--- | :--- | :--- | +| `/set logs` | Configure audit logging target channel | `/set logs channel: #audit-log` | +| `/set tickets` | Configure support ticket panel and transcripts | `/set tickets channel: #tickets transcript: #transcripts` | +| `/set welcome` | Configure welcome message channel and template | `/set welcome channel: #welcome message: "Welcome {user}!"` | +| `/set leave` | Configure farewell message channel and template | `/set leave channel: #leave message: "Goodbye {user}!"` | +| `/set suggestions` | Configure user suggestions channel | `/set suggestions channel: #suggestions` | +| `/set verification` | Configure member verification channel and verified role | `/set verification channel: #verify role: @Member` | diff --git a/wiki/Commands-Utility.md b/wiki/Commands-Utility.md new file mode 100644 index 000000000..0f1ec7cb5 --- /dev/null +++ b/wiki/Commands-Utility.md @@ -0,0 +1,40 @@ +# ๐ŸŽฎ Utility & Fun Commands Reference + +Complete reference for utility, gaming, search, news, and entertainment commands. + +--- + +## Utility & Info Commands + +| Command | Description | Usage | +| :--- | :--- | :--- | +| `/help` | Interactive command browser with category select menu | `/help` | +| `/about` | Bot and system statistics | `/about` | +| `/dashboard` | Link to the embedded web management dashboard | `/dashboard` | +| `/poll` | Interactive multi-choice poll with buttons | `/poll title: "Vote" option1: "A" option2: "B"` | +| `/reminder` | Personal and channel scheduled reminders | `/reminder set duration: 1h text: "Pizza"` | +| `/weather` | Current weather and 3-day forecast | `/weather location: London` | +| `/world-news` | Headlines via NewsAPI | `/world-news category: technology` | +| `/translate` | Google Translate utility | `/translate language: english text: "..."` | +| `/game-search` | Video game info via IGDB | `/game-search query: "Elden Ring"` | +| `/tv-show-search` | TV show information via TVMaze | `/tv-show-search query: "Breaking Bad"` | +| `/twitch-status` | Live status for Twitch streamer | `/twitch-status streamer: bacon_fixation` | +| `/urban` | Urban Dictionary slang definition search | `/urban query: javascript` | + +--- + +## Games & Entertainment Commands + +| Command | Description | Usage | +| :--- | :--- | :--- | +| `/connect-four` | Interactive 2-player Connect 4 game | `/connect-four opponent: @user` | +| `/tic-tac-toe` | Interactive 2-player Tic-Tac-Toe game | `/tic-tac-toe opponent: @user` | +| `/rps` | Rock Paper Scissors vs bot | `/rps choice: rock` | +| `/8ball` | Magic 8-Ball answer generator | `/8ball question: "Is this bot awesome?"` | +| `/bored` | Random fun activity generator | `/bored` | +| `/fortune` | Fortune cookie wisdom | `/fortune` | +| `/motivation` | Motivational quote | `/motivation` | +| `/random` | Random number generator | `/random min: 1 max: 100` | +| `/chucknorris` | Satirical Chuck Norris joke | `/chucknorris` | +| `/kanye` | Random Kanye quote | `/kanye` | +| `/insult` | Evil insult generator | `/insult` | diff --git a/wiki/Commands.md b/wiki/Commands.md new file mode 100644 index 000000000..5099702d9 --- /dev/null +++ b/wiki/Commands.md @@ -0,0 +1,12 @@ +# ๐Ÿ“œ Commands Reference Hub + +Master-Bot provides **74 production slash commands** organized across 4 primary categories. + +--- + +## ๐Ÿ“š Command Categories + +- [๐ŸŽต **Music & Playlist Commands**](Commands-Music): Playback, queue controls, playlists, audio filters, music trivia. +- [๐Ÿ”จ **Moderation Commands**](Commands-Moderation): Ban, kick, purge, slowmode, timeout with permission checks. +- [๐ŸŽฎ **Utility & Fun Commands**](Commands-Utility): Polls, reminders, weather, news, game search, TV search, mini-games, GIFs. +- [โš™๏ธ **Server Configuration (`/set`)**](Commands-Server-Settings): Guild configuration for audit logs, tickets, welcome messages, suggestions, verification. diff --git a/wiki/Configuration.md b/wiki/Configuration.md new file mode 100644 index 000000000..25c082f53 --- /dev/null +++ b/wiki/Configuration.md @@ -0,0 +1,68 @@ +# ๐Ÿ”‘ Configuration & Environment Variables Guide + +Comprehensive configuration reference for all environment variables in Master-Bot. + +--- + +## Complete `.env` Configuration Template + +```env +# SQLite Database (auto-created at /data/bot.sqlite on first start) +# DISCORD_DB_PATH="/absolute/path/to/bot.sqlite" + +# Unified Runtime Port (bot + embedded dashboard + OAuth2 share ONE port) +PORT=3000 + +# Discord Bot Credentials +DISCORD_TOKEN="" +DISCORD_CLIENT_ID="" +DISCORD_CLIENT_SECRET="" +DISCORD_OWNER_ID="" + +# Dashboard / OAuth2 (optional โ€” auto-resolved from PORT) +# NEXTAUTH_URL="https://your-domain.com" +# NEXTAUTH_SECRET="your_32_character_session_secret" + +# Lavalink v4 Audio Engine +LAVA_ENABLED=true +LAVA_HOST="127.0.0.1" +LAVA_PORT=2333 +LAVA_PASS="youshallnotpass" +LAVA_SECURE=false +LAVA_EXTERNAL=false + +# Spotify Metadata +SPOTIFY_CLIENT_ID="" +SPOTIFY_CLIENT_SECRET="" + +# Twitch Stream Alerts & IGDB +TWITCH_ENABLED=false +TWITCH_CLIENT_ID="" +TWITCH_CLIENT_SECRET="" +IGDB_ENABLED=false + +# Media & Search APIs +GIFS_ENABLED=true +KLIPY_API="" +NEWS_ENABLED=false +NEWS_API="" +GENIUS_API="" +``` + +--- + +## ๐Ÿšฉ Dynamic Feature Flags + +| Variable | Default | Description | +| :--- | :--- | :--- | +| `LAVA_ENABLED` | `false` | Global toggle for Lavalink audio and all music commands | +| `GIFS_ENABLED` | `true` | Enables animated GIF reactions and media commands | +| `TWITCH_ENABLED` | `true` | Enables Twitch streamer monitoring and live alerts | +| `NEWS_ENABLED` | `true` | Enables global news headlines via NewsAPI (`/world-news`) | +| `IGDB_ENABLED` | `true` | Enables video game search via IGDB (`/game-search`) | + +--- + +## Detailed Credentials Setup + +See [API Keys & Integrations Guide](API-Keys) for step-by-step instructions on acquiring credentials. diff --git a/wiki/Dashboard-Architecture.md b/wiki/Dashboard-Architecture.md new file mode 100644 index 000000000..33f672863 --- /dev/null +++ b/wiki/Dashboard-Architecture.md @@ -0,0 +1,37 @@ +# ๐Ÿ›๏ธ Web Dashboard Technical Architecture + +Technical architecture of `apps/dashboard` and how it lives inside `apps/bot`. + +--- + +## Architecture Flow + +```mermaid +flowchart TD + subgraph Process["Master-Bot Single Process (unified PORT)"] + Bot["apps/bot src/index.ts
(Discord client + setup)"] + Server["apps/bot src/server.ts
(Node http server)"] + Dash["apps/dashboard src/router.ts
(route handler)"] + DataService["apps/bot src/dataService.ts
(typed facade)"] + DB["packages/db BotDatabase
(node:sqlite)"] + end + + Browser["Owner Browser
/dashboard"] + + Browser --> Server + Server --> Dash + Dash --> DataService + DataService --> DB + Dash --> Auth["apps/dashboard src/auth
(NextAuth-compatible sessions)"] + Bot --> Server +``` + +--- + +## How the Pieces Fit + +1. **Bootstrap**: `apps/bot/src/index.ts` calls `setDatabasePath(getDbPath())` (SQLite at `/data/bot.sqlite` or `DISCORD_DB_PATH`), builds a `DashboardContext`, injects it via `setDashboardContext()`, then starts the HTTP server on `PORT`. +2. **Routing**: `apps/dashboard/src/router.ts` (`routeDashboardRequest`) serves the UI shell, `/invite` redirect, `/api/auth/*` (OAuth2 callbacks/session), `/api/dashboard/stats`, `/api/dashboard/guilds`, and `/api/dashboard/bot/*` action endpoints. Anything unhandled falls through to the bot's 404. +3. **Data access**: Handlers never touch `process.env` or SQL directly โ€” they call the `dataService` facade (`apps/bot/src/dataService.ts`) which mirrors the original tRPC router shapes and wraps `BotDatabase` CRUD. +4. **Auth**: `apps/dashboard/src/auth/config.ts` + `handlers.ts` implement Discord OAuth2 with HMAC-signed session cookies compatible with the original NextAuth cookie format (`next-auth.session-token`). +5. **Env**: All keys flow through `apps/bot/src/env.ts` โ€” one shared env layer for both bot and dashboard (`PORT`, `DISCORD_CLIENT_ID`, `NEXTAUTH_SECRET`, โ€ฆ). \ No newline at end of file diff --git a/wiki/Dashboard-Studios.md b/wiki/Dashboard-Studios.md new file mode 100644 index 000000000..720d119e0 --- /dev/null +++ b/wiki/Dashboard-Studios.md @@ -0,0 +1,38 @@ +# ๐ŸŽ›๏ธ Web Dashboard Feature Studios Guide + +In-depth guide for the 9 dedicated feature studios in the Master-Bot Web Dashboard: + +--- + +## The 9 Feature Studios + +1. **๐ŸŽต Audio & Music Studio** (`/dashboard/music`): + - Real-time Lavalink player state, track search, queue browser. + - Interactive DSP audio filters (Bassboost, Nightcore, Vaporwave, Karaoke). + - Volume sliders & user playlist synchronizer. + +2. **๐Ÿ“ข WYSIWYG Broadcaster** (`/dashboard/broadcast`): + - Real-time side-by-side visual Discord embed composer. + - Title, description, colors, fields, thumbnails, and footer designer. + - Direct Discord API v10 channel message dispatcher. + +3. **๐Ÿ“œ 18-Event Audit Stream** (`/dashboard/logs`): + - Comprehensive event logging categorized by Moderation, Messages, Members, Channels, and Voice. + +4. **๐ŸŽซ Support Ticket Suite** (`/dashboard/[server_id]/tickets`): + - Ticket manager roles, dynamic transcript routing, custom panel greeting messages. + +5. **๐Ÿ“บ Twitch Integrations** (`/dashboard/integrations`): + - Live streamer tracking and notification routing. + +6. **โšก Cluster Telemetry** (`/dashboard/system`): + - Live PostgreSQL query latency (`SELECT 1`), gateway WebSocket ping, shard metrics, ecosystem statistics. + +7. **โฐ Reminders Studio** (`/dashboard/reminders`): + - Multi-channel reminder manager and recurring scheduler. + +8. **๐Ÿ‘‹ Welcome & Leave Greetings** (`/dashboard/[server_id]/welcome-message`): + - Welcome embed designer with dynamic template variables (`{user}`, `{server}`, `{position}`). + +9. **โš™๏ธ Command Controls** (`/dashboard/[server_id]/commands`): + - Guild-level command overrides and permission bit management. diff --git a/wiki/Dashboard.md b/wiki/Dashboard.md new file mode 100644 index 000000000..a8dddc074 --- /dev/null +++ b/wiki/Dashboard.md @@ -0,0 +1,23 @@ +# ๐ŸŒ Web Dashboard Hub + +The official web management portal and command center for **Master-Bot**. Since the HELIX rebuild it is **not** a separate Next.js app โ€” it's a dependency-light Node.js `http` server (`apps/dashboard`) **embedded inside the bot process** (`apps/bot/src/server.ts`). The bot, dashboard, and OAuth2 login all share one unified port (default `3000`). + +--- + +## ๐ŸŽจ Command Center + +The dashboard renders a dark glassmorphism interface (Tailwind CSS via CDN, CSS `backdrop-filter`) with: + +- **Live telemetry** โ€” guild/command/uptime stats served from `BotDatabase` and the running bot (`/api/dashboard/stats`) +- **Guild overview** โ€” server listing with settings and actions (`/api/dashboard/guilds`) +- **Bot actions** โ€” zero-lag server-side actions dispatched through the `dataService` facade (`/api/dashboard/bot/*`) +- **OAuth2 login** โ€” NextAuth-compatible Discord session flow (`/api/auth/*`), cookie format mirrored from the original NextAuth implementation + +Access it at `http://localhost:3000/dashboard` (or whatever `PORT` is set to). + +--- + +## ๐Ÿ“š Dedicated Dashboard Sub-Guides + +- [๐Ÿ›๏ธ **Technical Architecture**](Dashboard-Architecture): Single-process embedding, route table, context injection, `dataService` + `BotDatabase` wiring. +- [๐ŸŽ›๏ธ **Feature Studios Guide**](Dashboard-Studios): Deep-dive into the dashboard studios (Music, Broadcaster, Audit Log, Support Tickets, Twitch, Telemetry, Reminders, Welcome Messages, Command Controls). \ No newline at end of file diff --git a/wiki/Docker-Deployment.md b/wiki/Docker-Deployment.md new file mode 100644 index 000000000..de7b618d9 --- /dev/null +++ b/wiki/Docker-Deployment.md @@ -0,0 +1,60 @@ +# ๐Ÿณ Docker Compose Deployment Guide + +Deploy the Master-Bot Docker ecosystem (**master-bot** app + **Lavalink v4** audio engine) locally or on a server using Docker. The dashboard is embedded in the bot process โ€” no separate dashboard container, PostgreSQL, or Redis required. + +--- + +## ๐Ÿ—๏ธ Docker Ecosystem Architecture + +```mermaid +flowchart TD + subgraph DockerNetwork["Docker Bridge Network (compose default)"] + BotContainer["master-bot
(Unified bot + embedded dashboard / Port 3000)"] + LavaContainer["master-bot-lavalink
(Lavalink v4 Java 21 / Port 2333)"] + Volume[("Host volume ./data
(SQLite: data/bot.sqlite)")] + end + + BotContainer --> LavaContainer + BotContainer --> Volume +``` + +--- + +## ๐Ÿš€ Quick Launch Steps + +1. **Clone Repository & Prepare Environment**: + ```bash + git clone https://github.com/galnir/Master-Bot.git + cd Master-Bot + cp .env.example .env + nano .env + ``` + +2. **Lavalink config**: copy `application.yml.example` to `application.yml` (mounted into the Lavalink container). + +3. **Launch the Stack**: + ```bash + docker compose --env-file docker.env up -d --build + ``` + The `./data` and `./logs` host folders are mounted into the container โ€” the SQLite database (auto-created) survives restarts there. + +4. **Check Container Status**: + ```bash + docker compose ps + ``` + +5. **View Live Logs**: + ```bash + # All containers + docker compose logs -f + + # Discord Bot (unified dashboard + bot logs) + docker compose logs -f master-bot + ``` + +6. **Access the Dashboard**: `http://localhost:3000/dashboard` + +7. **Stop Stack**: + ```bash + docker compose down + ``` \ No newline at end of file diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 000000000..8b40e7b94 --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,69 @@ +# ๐Ÿ“– Master-Bot Wiki + +Welcome to the official **Master-Bot** documentation wiki. Master-Bot is a full-stack, production-grade Discord Bot with an embedded web dashboard built with **TypeScript**, **Sapphire Framework**, **discord.js v14**, **Node.js 22 (`node:sqlite`)**, and **Lavalink v4**. The Discord client, web dashboard, and OAuth2 login run together in a **single process** on one unified port (default `3000`). + +--- + +## ๐Ÿ—บ๏ธ System Architecture + +```mermaid +flowchart TD + subgraph Process["Single Master-Bot Process (unified PORT)"] + Bot["apps/bot
(Sapphire Framework & discord.js v14)"] + Dash["apps/dashboard
(embedded Node.js HTTP server)"] + end + + subgraph Packages["Shared Packages (packages/)"] + DB["packages/db
(node:sqlite BotDatabase)"] + Config["packages/config
(ESLint)"] + end + + subgraph Storage["Storage & Media Layer"] + SQLite[("SQLite Database
(data/bot.sqlite)")] + MemQueue["In-Memory Audio Queue Engine"] + Lava["Lavalink v4 Audio Server"] + Discord["Discord Gateway & REST API v10"] + end + + Dash --> Bot + Dash --> DB + Bot --> DB + DB --> SQLite + Bot --> Lava + Bot --> MemQueue + Bot --> Discord +``` + +--- + +## ๐Ÿ“š Wiki Sections Hub + +| Section | Description | Top-Level Guide | +| :--- | :--- | :--- | +| **โš™๏ธ Getting Started** | Local setup for Windows, macOS, Linux, Raspberry Pi, and Docker | [Setup Guide](Setup) | +| **โ˜๏ธ Cloud Hosting** | Manual production deployment across Render, Railway, Fly.io, Heroku, Koyeb, VPS | [Hosting Guide](Hosting) | +| **๐ŸŽต Lavalink & Audio** | Lavalink v4 setup, YouTube OAuth device flow, cipher deciphering, audio filters | [Lavalink Guide](Lavalink) | +| **๐ŸŒ Web Dashboard** | Embedded Node.js HTTP dashboard, OAuth2 login, live stats, and settings | [Dashboard Guide](Dashboard) | +| **๐Ÿ”‘ Configuration** | Master-Bot environment variables, API keys (Twitch, IGDB, Klipy, NewsAPI), feature flags | [Configuration Guide](Configuration) | +| **๐Ÿ“œ Commands** | Complete 74 slash command catalog and server configuration (`/set`) manual | [Commands Reference](Commands) | +| **๐Ÿงช Testing** | Vitest unit and integration test harness, coverage, and validation workflows | [Testing Guide](Testing) | + +--- + +## โšก Quick Start (Local Development) + +```bash +# 1. Clone repository +git clone https://github.com/galnir/Master-Bot.git +cd Master-Bot + +# 2. Install dependencies +pnpm install + +# 3. Configure environment +cp .env.example .env +nano .env + +# 4. Launch unified dev stack (Bot, Dashboard, Lavalink) +pnpm dev +``` diff --git a/wiki/Hosting-Fly-io.md b/wiki/Hosting-Fly-io.md new file mode 100644 index 000000000..25e67dda4 --- /dev/null +++ b/wiki/Hosting-Fly-io.md @@ -0,0 +1,47 @@ +# โœˆ๏ธ Deploying on Fly.io (fly.io) + +Manual deployment instructions using the Fly CLI. The bot embeds the dashboard; SQLite is stored on a Fly.io volume โ€” no PostgreSQL or Redis needed. + +--- + +## 1. Initialize & Add a Volume + +```bash +# Initialize App +fly launch --no-deploy + +# Create a persistent volume for the SQLite database +fly volumes create data --size 1 --region ord +``` + +## 2. Configure Dockerfile & Mounts + +Master-Bot ships a production `Dockerfile` (Node 22). Mount the SQLite volume where the app expects it: + +```toml +# fly.toml +[mounts] +source = "data" +destination = "/data" + +[env] +DISCORD_DB_PATH = "/data/bot.sqlite" +``` + +## 3. Set Secrets & Deploy + +```bash +# Set Secrets +fly secrets set \ + DISCORD_TOKEN="your_bot_token" \ + DISCORD_CLIENT_ID="your_client_id" \ + DISCORD_CLIENT_SECRET="your_client_secret" \ + NEXTAUTH_SECRET="your_32_char_secret" \ + NEXTAUTH_URL="https://master-bot.fly.dev" \ + LAVA_ENABLED=false + +# Deploy App +fly deploy +``` + +Add `https://master-bot.fly.dev/api/auth/callback/discord` to your Discord Developer Portal OAuth2 redirects. \ No newline at end of file diff --git a/wiki/Hosting-Heroku.md b/wiki/Hosting-Heroku.md new file mode 100644 index 000000000..4f0aafcf4 --- /dev/null +++ b/wiki/Hosting-Heroku.md @@ -0,0 +1,53 @@ +# ๐ŸŸฃ Deploying on Heroku (heroku.com) + +Manual deployment instructions for Heroku using Buildpacks and a single Dyno. The bot embeds the dashboard, and SQLite needs no add-ons. + +--- + +## 1. Create Application + +```bash +# Create Heroku App +heroku create master-bot-prod + +# Add official Node.js buildpack +heroku buildpacks:add heroku/nodejs -a master-bot-prod +``` + +> The `package.json` engines require Node 22+, which Heroku's default stack resolves automatically. + +--- + +## 2. Configure `Procfile` + +Ensure a `Procfile` exists in repository root โ€” one `web` process serves both the bot and dashboard: + +```text +web: pnpm start +``` + +--- + +## 3. Set Config Vars & Deploy + +```bash +# Set environment variables +heroku config:set \ + NODE_ENV=production \ + NPM_CONFIG_PRODUCTION=false \ + DISCORD_TOKEN="your_bot_token" \ + DISCORD_CLIENT_ID="your_client_id" \ + DISCORD_CLIENT_SECRET="your_client_secret" \ + NEXTAUTH_SECRET="generate_random_32_char_secret" \ + NEXTAUTH_URL="https://master-bot-prod.herokuapp.com" \ + LAVA_ENABLED=false \ + -a master-bot-prod + +# Deploy to Heroku +git push heroku main + +# Scale a single dyno +heroku ps:scale web=1 -a master-bot-prod +``` + +Heroku sets `PORT` automatically; the bot, dashboard, and OAuth2 callback all serve from it. The SQLite database is auto-created (use an ephemeral filesystem add-on or `DISCORD_DB_PATH` on a persistent volume to keep data across deploys). \ No newline at end of file diff --git a/wiki/Hosting-Koyeb.md b/wiki/Hosting-Koyeb.md new file mode 100644 index 000000000..780b576c2 --- /dev/null +++ b/wiki/Hosting-Koyeb.md @@ -0,0 +1,23 @@ +# ๐ŸŸข Deploying on Koyeb (koyeb.com) + +Manual deployment instructions using Koyeb Console. Deploy the bot as a **single Web Service** โ€” the dashboard is embedded and SQLite requires no PostgreSQL. + +--- + +## 1. Deploy Master-Bot (Web Service) + +1. Click **Create Service** -> **GitHub**. +2. Select repository and set: + - **Type**: Web Service + - **Build Command**: `pnpm install && pnpm build` + - **Run Command**: `pnpm start` + - **Port**: `3000` +3. Add environment variables: `NEXTAUTH_SECRET`, `NEXTAUTH_URL`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_TOKEN`, `LAVA_ENABLED`. + +## 2. Persistent Volume (SQLite) + +Create a volume mounted at `/data` and set `DISCORD_DB_PATH=/data/bot.sqlite` so the auto-created SQLite database survives redeploys. + +## 3. Discord Redirect + +Add `https://.koyeb.app/api/auth/callback/discord` to your Discord Developer Portal OAuth2 redirects. \ No newline at end of file diff --git a/wiki/Hosting-Northflank.md b/wiki/Hosting-Northflank.md new file mode 100644 index 000000000..a0ceaa742 --- /dev/null +++ b/wiki/Hosting-Northflank.md @@ -0,0 +1,13 @@ +# ๐Ÿ”ท Deploying on Northflank (northflank.com) + +Manual deployment instructions for Northflank projects. The bot embeds the dashboard and uses embedded SQLite โ€” no PostgreSQL or Redis add-ons needed. + +--- + +1. **Create Project**: Create a new Northflank project. +2. **Add Service**: Deploy the repository as a single **Combined Service**. + - **Build**: Node.js buildpack or the repository `Dockerfile`. + - **Port**: Expose port `3000` via the auto-generated HTTPS domain. +3. **Persistent Volume**: Add a volume mounted at `/data` and set `DISCORD_DB_PATH=/data/bot.sqlite` to persist the SQLite database. +4. **Environment**: Provide `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_OWNER_ID`, `NEXTAUTH_URL`, and `NEXTAUTH_SECRET`. +5. **Discord Redirect**: Add `https://..northflank.app/api/auth/callback/discord` to your Discord Developer Portal OAuth2 redirects. \ No newline at end of file diff --git a/wiki/Hosting-Pterodactyl.md b/wiki/Hosting-Pterodactyl.md new file mode 100644 index 000000000..a6da36368 --- /dev/null +++ b/wiki/Hosting-Pterodactyl.md @@ -0,0 +1,27 @@ +# ๐Ÿฆ… Pterodactyl Panel Deployment Guide + +Deploy Master-Bot to a Pterodactyl Game & App server panel using a generic Node.js egg. The bot embeds the dashboard, and SQLite is stored in the panel's persistent file area. + +--- + +## 1. Panel Configuration + +1. **Egg Selection**: Use a **Node.js 22+** egg. +2. **File Upload**: Upload repository files or clone via Git in the file manager. +3. **Startup Command**: + ```bash + pnpm install --ignore-scripts && pnpm build && pnpm start + ``` +4. **Port**: Set the startup port to `3000` (match the `PORT` variable). + +--- + +## 2. Environment Variables + +Populate the required environment variables in the **Startup** tab: +- `DISCORD_TOKEN` +- `DISCORD_CLIENT_ID` +- `DISCORD_CLIENT_SECRET` +- `DISCORD_OWNER_ID` +- `PORT=3000` +- Optional: `DISCORD_DB_PATH` (defaults to `data/bot.sqlite` in the workspace โ€” persists on the panel) \ No newline at end of file diff --git a/wiki/Hosting-Railway.md b/wiki/Hosting-Railway.md new file mode 100644 index 000000000..be78649c5 --- /dev/null +++ b/wiki/Hosting-Railway.md @@ -0,0 +1,28 @@ +# ๐Ÿš† Deploying on Railway (railway.app) + +Manual step-by-step instructions for deploying Master-Bot to Railway as a **single service**. The bot embeds the dashboard; SQLite needs no external databases. + +--- + +## Step 1: Add the Master-Bot Service + +1. Open [Railway Dashboard](https://railway.app/dashboard) and click **New Project**. +2. Click **Create** -> **GitHub Repo** and select your repository. + +## Step 2: Configure the Service + +1. Open service **Settings**: + - **Service Name**: `master-bot` + - **Custom Build Command**: `pnpm install && pnpm build` + - **Custom Start Command**: `pnpm start` +2. Under **Networking**, click **Generate Domain**. +3. Add a **Volume** mounted at `/data` for the SQLite database. + +## Step 3: Add Environment Variables + +- `DISCORD_DB_PATH`: `/data/bot.sqlite` +- `NEXTAUTH_URL`: `https://${{RAILWAY_PUBLIC_DOMAIN}}` +- `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_OWNER_ID` +- `LAVA_ENABLED`: `false` + +Railway sets `PORT` automatically; add the generated domain's `/api/auth/callback/discord` redirect to your Discord Developer Portal OAuth2 settings. \ No newline at end of file diff --git a/wiki/Hosting-Render.md b/wiki/Hosting-Render.md new file mode 100644 index 000000000..55a8c005f --- /dev/null +++ b/wiki/Hosting-Render.md @@ -0,0 +1,33 @@ +# ๐Ÿš€ Deploying on Render (render.com) + +Manual step-by-step instructions for deploying Master-Bot to Render as a **single Web Service**. The bot embeds the dashboard; SQLite requires no external database. + +--- + +## Step 1 (Optional): Provision a Persistent Disk + +The SQLite database lives at `/data/bot.sqlite`. To keep data across deploys, attach a **Persistent Disk** to the Web Service and mount it at `/opt/render/project/data`. + +--- + +## Step 2: Deploy Master-Bot (Web Service) + +1. In Render Dashboard, click **New +** -> **Web Service**. +2. Connect your GitHub repository. +3. Configure settings: + - **Name**: `master-bot` + - **Language**: `Node` + - **Branch**: `main` + - **Build Command**: `pnpm install && pnpm build` + - **Start Command**: `pnpm start` +4. Add Environment Variables: + - `PORT`: `3000` (Render injects its own `PORT` too) + - `DISCORD_DB_PATH`: `/opt/render/project/data/bot.sqlite` (if a Persistent Disk is mounted) + - `NEXTAUTH_URL`: `https://.onrender.com` + - `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_OWNER_ID` + - `LAVA_ENABLED`: `false` (or an external Lavalink node host/pass) +5. Under your Discord Developer Portal OAuth2 settings, add: + - `https://.onrender.com/api/auth/callback/discord` +6. Click **Create Web Service**. + +Dashboard: `https://.onrender.com/dashboard` \ No newline at end of file diff --git a/wiki/Hosting-VPS.md b/wiki/Hosting-VPS.md new file mode 100644 index 000000000..0a2e22d57 --- /dev/null +++ b/wiki/Hosting-VPS.md @@ -0,0 +1,63 @@ +# ๐Ÿง Self-Hosted Linux VPS & Systemd Guide + +Deploy Master-Bot directly to an Ubuntu/Debian/RHEL Virtual Private Server using a native Systemd service. The bot and dashboard run as a single process โ€” no PostgreSQL or Redis required (SQLite is embedded). + +--- + +## 1. Install Prerequisites + +```bash +sudo apt update +sudo apt install -y nodejs npm openjdk-21-jre +sudo npm install -g pnpm +``` + +Ensure Node.js is **22 or newer** (`node --version`). + +--- + +## 2. Setup Project & Database + +```bash +git clone https://github.com/galnir/Master-Bot.git /opt/master-bot +cd /opt/master-bot +cp .env.example .env +nano .env +pnpm install +pnpm build +``` + +The SQLite database is auto-created at `/opt/master-bot/data/bot.sqlite` on first start. Set `DISCORD_DB_PATH` if you want it elsewhere. + +--- + +## 3. Create Systemd Service (`/etc/systemd/system/master-bot.service`) + +```ini +[Unit] +Description=Master-Bot Discord Application (bot + embedded dashboard) +After=network.target + +[Service] +Type=simple +User=ubuntu +WorkingDirectory=/opt/master-bot +ExecStart=/usr/bin/pnpm start +Restart=always +RestartSec=10 +EnvironmentFile=/opt/master-bot/.env + +[Install] +WantedBy=multi-user.target +``` + +--- + +## 4. Enable & Start Service + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now master-bot +``` + +Dashboard: `http://:3000/dashboard` \ No newline at end of file diff --git a/wiki/Hosting.md b/wiki/Hosting.md new file mode 100644 index 000000000..763ec689f --- /dev/null +++ b/wiki/Hosting.md @@ -0,0 +1,25 @@ +# โ˜๏ธ Cloud & Platform Hosting Hub + +Comprehensive manual step-by-step deployment instructions for hosting **Master-Bot** across major cloud platforms. The Discord client, embedded web dashboard, and OAuth2 login run as a **single process** using embedded SQLite โ€” no PostgreSQL or Redis. + +--- + +## ๐Ÿ—บ๏ธ Supported Platform Guides + +| Platform | Type | Dedicated Guide | +| :--- | :--- | :--- | +| **๐Ÿš€ Render** | Single Web Service (persistent disk for SQLite) | [Render Hosting Guide](Hosting-Render) | +| **๐Ÿš† Railway** | Single Service (volume for SQLite) | [Railway Hosting Guide](Hosting-Railway) | +| **โœˆ๏ธ Fly.io** | MicroVM App (volume for SQLite) | [Fly.io Hosting Guide](Hosting-Fly-io) | +| **๐ŸŸฃ Heroku** | Single Web Dyno | [Heroku Hosting Guide](Hosting-Heroku) | +| **๐ŸŸข Koyeb** | Single Web Service (volume for SQLite) | [Koyeb Hosting Guide](Hosting-Koyeb) | +| **๐Ÿ”ท Northflank** | Single Deployment Service | [Northflank Hosting Guide](Hosting-Northflank) | +| **๐Ÿง Linux VPS** | Systemd / Docker | [Linux VPS Guide](Hosting-VPS) | +| **๐Ÿฆ… Pterodactyl** | App / Bot Egg | [Pterodactyl Guide](Hosting-Pterodactyl) | +| **๐Ÿณ Docker** | docker-compose (bot + Lavalink) | [Docker Deployment Guide](Docker-Deployment) | + +--- + +## ๐Ÿ”‘ Master-Bot Environment Variables Reference + +See the full [Configuration Guide](Configuration) for complete details on all required environment variables. diff --git a/wiki/Lavalink-Audio-Filters.md b/wiki/Lavalink-Audio-Filters.md new file mode 100644 index 000000000..f2166ebcf --- /dev/null +++ b/wiki/Lavalink-Audio-Filters.md @@ -0,0 +1,16 @@ +# ๐ŸŽ›๏ธ Lavalink Real-Time Audio DSP Filters + +Master-Bot provides real-time DSP audio filters powered by Lavalink: + +--- + +## Available Audio Filters + +| Filter Command | Description | Example | +| :--- | :--- | :--- | +| **/bassboost** | Boosts bass frequencies with selectable levels (`low`, `medium`, `high`, `extreme`) | `/bassboost level: high` | +| **/nightcore** | Increases tempo and pitch for an upbeat remix | `/nightcore` | +| **/vaporwave** | Decreases tempo and lowers pitch for a retro vibe | `/vaporwave` | +| **/karaoke** | Suppresses centered vocal frequencies | `/karaoke` | +| **/seek** | Seeks to a specific timestamp in playback | `/seek 2:15` | +| **/volume** | Adjusts playback volume (1-200%) | `/volume 80` | diff --git a/wiki/Lavalink-Configuration.md b/wiki/Lavalink-Configuration.md new file mode 100644 index 000000000..bba52f36a --- /dev/null +++ b/wiki/Lavalink-Configuration.md @@ -0,0 +1,27 @@ +# โš™๏ธ Lavalink Configuration Guide (`application.yml`) + +Detailed breakdown of Lavalink v4 configuration and plugin management. + +--- + +## Configuration File Template + +A preconfigured template is provided at `application.yml.example`. Copy it to `application.yml`: + +```bash +cp application.yml.example application.yml +``` + +--- + +## Key Plugin Configurations + +### 1. YouTube Plugin (`youtube-plugin:1.18.2`) +- **Remote Cipher**: Offloads YouTube signature deciphering to `https://cipher.kikkia.dev/` or custom `YOUTUBE_CIPHER_URL`. +- **InnerTube Clients**: Configures `MUSIC` (`WEB_REMIX`), `ANDROID_VR`, `WEB`, `WEBEMBEDDED`, `IOS`, and `TV` clients for playback. + +### 2. LavaSrc Plugin (`lavasrc-plugin:4.8.3`) +- Enables Spotify track/album/playlist metadata resolution via ISRC and search fallback. + +### 3. Built-In SoundCloud Source +- Free built-in full-length track streaming (`filterOutPreviewTracks: true`) without paid API keys. diff --git a/wiki/Lavalink-Nodes.md b/wiki/Lavalink-Nodes.md new file mode 100644 index 000000000..27182f2aa --- /dev/null +++ b/wiki/Lavalink-Nodes.md @@ -0,0 +1,38 @@ +# ๐ŸŒ Lavalink Node Topologies + +Master-Bot supports three Lavalink node connection topologies: + +--- + +## Topology A: Internal Local Server (Development & Docker) +```env +LAVA_ENABLED=true +LAVA_EXTERNAL=false +LAVA_HOST="127.0.0.1" +LAVA_PORT=2333 +LAVA_PASS="youshallnotpass" +``` + +--- + +## Topology B: Dedicated External Server (Production) +```env +LAVA_ENABLED=true +LAVA_EXTERNAL=true +LAVA_HOST="lava.yourdomain.com" +LAVA_PORT=443 +LAVA_PASS="your_secure_password" +LAVA_SECURE=true +``` + +--- + +## Topology C: Public Community Nodes +```env +LAVA_ENABLED=true +LAVA_EXTERNAL=true +LAVA_HOST="public-node.example.com" +LAVA_PORT=2333 +LAVA_PASS="public_pass" +LAVA_SECURE=false +``` diff --git a/wiki/Lavalink-YouTube-OAuth.md b/wiki/Lavalink-YouTube-OAuth.md new file mode 100644 index 000000000..9dcbeee1e --- /dev/null +++ b/wiki/Lavalink-YouTube-OAuth.md @@ -0,0 +1,24 @@ +# ๐Ÿ”‘ Lavalink YouTube OAuth Device Flow + +YouTube playback requires OAuth 2.0 device flow authentication to bypass bot verification checks and rate limits. + +--- + +## How YouTube Device Authorization Works + +1. On startup without a refresh token, Lavalink outputs an OAuth verification prompt to the terminal console: + - **Verification Link**: `https://www.google.com/device` + - **User Code**: `XXXX-XXXX` +2. Visit the link in your browser and authorize the device code. +3. The launcher automatically captures the issued token, saves it atomically to `.youtube-oauth.json` (gitignored), and sets `process.env.YOUTUBE_REFRESH_TOKEN`. +4. Tokens persist across reboots without modifying `.env` on disk. + +--- + +## Owner Slash Command + +The bot owner can re-trigger authorization at any time using: + +```text +/youtube-auth +``` diff --git a/wiki/Lavalink.md b/wiki/Lavalink.md new file mode 100644 index 000000000..12c37fcac --- /dev/null +++ b/wiki/Lavalink.md @@ -0,0 +1,37 @@ +# ๐ŸŽต Lavalink v4 Audio Engine Hub + +Master-Bot uses **Lavalink v4** for low-latency, cross-platform audio streaming. + +--- + +## ๐Ÿ—บ๏ธ Audio Architecture + +```mermaid +flowchart TD + User["Discord User (/play)"] --> SapphireBot["Master-Bot (Sapphire)"] + SapphireBot -->|WebSocket (Port 2333)| Lavalink["Lavalink v4 Audio Server"] + + subgraph Lavalink Engine + YouTubePlugin["youtube-plugin (1.18.2)"] + LavaSrc["lavasrc-plugin (Spotify / Apple)"] + SoundCloud["SoundCloud Audio Source"] + end + + Lavalink --> YouTubePlugin + Lavalink --> LavaSrc + Lavalink --> SoundCloud + + YouTubePlugin -->|OAuth Device Flow| GoogleOAuth["Google / YouTube OAuth"] + GoogleOAuth -->|Atomic Write| TokenFile[".youtube-oauth.json"] + TokenFile -->|Spring Binding| Lavalink + Lavalink -->|Direct Opus Stream| VoiceChannel["Discord Voice Channel"] +``` + +--- + +## ๐Ÿ“š Dedicated Audio Sub-Guides + +- [โš™๏ธ **Server Configuration (`application.yml`)**](Lavalink-Configuration): Plugins, remote cipher server, YouTube InnerTube clients. +- [๐Ÿ”‘ **YouTube OAuth Device Flow**](Lavalink-YouTube-OAuth): Terminal authorization prompts, `/youtube-auth` command, atomic token persistence. +- [๐ŸŽ›๏ธ **Audio DSP Filters**](Lavalink-Audio-Filters): Bassboost, Nightcore, Vaporwave, Karaoke, seek. +- [๐ŸŒ **Lavalink Node Topologies**](Lavalink-Nodes): Internal local server vs dedicated VPS vs public community nodes. diff --git a/wiki/Setup-Linux.md b/wiki/Setup-Linux.md new file mode 100644 index 000000000..5038b792c --- /dev/null +++ b/wiki/Setup-Linux.md @@ -0,0 +1,54 @@ +# ๐Ÿง Linux Setup Guide + +Detailed instructions for installing and running Master-Bot on Linux distributions. + +--- + +## 1. Ubuntu / Debian + +```bash +# 1. Install Node.js 20 LTS & pnpm +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs +sudo npm install -g pnpm + +# 2. Install OpenJDK 21 (for Lavalink audio engine) +sudo apt install -y openjdk-21-jre-headless +``` + +--- + +## 2. Arch Linux + +```bash +sudo pacman -S nodejs npm pnpm jdk21-openjdk +``` + +--- + +## 3. Fedora / RHEL / Rocky Linux + +```bash +sudo dnf module install -y nodejs:20 +sudo npm install -g pnpm +sudo dnf install -y java-21-openjdk +``` + +--- + +## 4. Database & Audio Queue + +Master-Bot uses **SQLite** (`file:./db.sqlite`) and an **In-Memory Audio Queue** out of the box. No PostgreSQL or Redis setup is required! + +--- + +## 4. Run Development Stack + +```bash +git clone https://github.com/galnir/Master-Bot.git +cd Master-Bot +pnpm install +cp .env.example .env +nano .env +pnpm dev +``` diff --git a/wiki/Setup-Raspberry-Pi.md b/wiki/Setup-Raspberry-Pi.md new file mode 100644 index 000000000..403f27dd6 --- /dev/null +++ b/wiki/Setup-Raspberry-Pi.md @@ -0,0 +1,46 @@ +# ๐Ÿ“ Raspberry Pi (ARM64) Setup Guide + +Instructions for running Master-Bot on Raspberry Pi 4 / 5 using Raspberry Pi OS (64-bit) or Debian ARM64. + +--- + +## 1. Install Prerequisites + +```bash +# Update package repositories +sudo apt update && sudo apt upgrade -y + +# Install Node.js 20 LTS & pnpm +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs +sudo npm install -g pnpm + +# Install Java 21 (for Lavalink audio engine) +sudo apt install -y openjdk-21-jre-headless +``` + +--- + +## 2. Performance & Memory Recommendations + +- **SWAP Allocation**: Ensure at least 2GB of swap is configured: + ```bash + sudo dphys-swapfile swapoff + sudo sed -i 's/CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/' /etc/dphys-swapfile + sudo dphys-swapfile setup + sudo dphys-swapfile swapon + ``` +- **Node Memory Limits**: If running on 2GB/4GB models, run with `--max-old-space-size=1024`. + +--- + +## 3. Launch Master-Bot + +```bash +git clone https://github.com/galnir/Master-Bot.git +cd Master-Bot +pnpm install +cp .env.example .env +nano .env +pnpm dev +``` diff --git a/wiki/Setup-Windows.md b/wiki/Setup-Windows.md new file mode 100644 index 000000000..4e4e4d520 --- /dev/null +++ b/wiki/Setup-Windows.md @@ -0,0 +1,49 @@ +# ๐ŸชŸ Windows Setup Guide + +Detailed instructions for installing and running Master-Bot locally on Windows 10/11. + +--- + +## 1. Install Prerequisites via `winget` + +Open **PowerShell as Administrator**: + +```powershell +# 1. Install Node.js LTS +winget install OpenJS.NodeJS.LTS + +# 2. Install pnpm +npm install -g pnpm + +# 3. Install OpenJDK 21 LTS (for Lavalink audio engine) +winget install Microsoft.OpenJDK.21 +``` + +--- + +## 2. Database & Audio Queue + +Master-Bot uses **SQLite** and an **In-Memory Audio Queue** out of the box. No PostgreSQL, Redis, or Memurai installation is required! + +--- + +## 3. PowerShell Execution Policy + +If PowerShell blocks running `pnpm` scripts: + +```powershell +Set-ExecutionPolicy RemoteSigned -Scope CurrentUser +``` + +--- + +## 4. Launch Development Stack + +```powershell +git clone https://github.com/galnir/Master-Bot.git +cd Master-Bot +pnpm install +cp .env.example .env +# Edit .env with your Discord Bot Token and Client ID +pnpm dev +``` diff --git a/wiki/Setup-macOS.md b/wiki/Setup-macOS.md new file mode 100644 index 000000000..e207eb3cc --- /dev/null +++ b/wiki/Setup-macOS.md @@ -0,0 +1,38 @@ +# ๐ŸŽ macOS Setup Guide + +Detailed instructions for installing and running Master-Bot locally on macOS using [Homebrew](https://brew.sh/). + +--- + +## 1. Install Prerequisites via Homebrew + +```bash +# Install Node.js LTS, pnpm, and OpenJDK 21 +brew install node@20 pnpm openjdk@21 + +# Link OpenJDK 21 to system Java wrappers +sudo ln -sfn /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-21.jdk + +# Add Node.js to your shell path (add to ~/.zshrc) +echo 'export PATH="/opt/homebrew/opt/node@20/bin:$PATH"' >> ~/.zshrc +source ~/.zshrc +``` + +--- + +## 2. Database & Audio Queue + +Master-Bot uses **SQLite** and an **In-Memory Audio Queue** out of the box. No PostgreSQL or Redis background services are needed! + +--- + +## 3. Launch Master-Bot + +```bash +git clone https://github.com/galnir/Master-Bot.git +cd Master-Bot +pnpm install +cp .env.example .env +# Edit .env with your credentials +pnpm dev +``` diff --git a/wiki/Setup.md b/wiki/Setup.md new file mode 100644 index 000000000..5819f4b19 --- /dev/null +++ b/wiki/Setup.md @@ -0,0 +1,41 @@ +# โš™๏ธ Getting Started & Setup Guide + +This guide covers system prerequisites, monorepo architecture, and local environment setup for Master-Bot. + +--- + +## ๐Ÿ“‹ System Prerequisites + +| Dependency | Minimum Version | Recommended Version | Purpose | +| :--- | :--- | :--- | :--- | +| **Node.js** | `>=18.0.0` | `20.x` or `22.x LTS` | JavaScript/TypeScript runtime | +| **pnpm** | `>=8.0.0` | `9.x` (`npm i -g pnpm`) | Monorepo package manager & workspace manager | +| **Java** | `Java 17+` | `Java 21 LTS` | Lavalink v4 audio engine runtime | +| **SQLite** | `Built-in` | `file:./db.sqlite` | Zero-config embedded relational database | +| **Audio Queue** | `Built-in` | `In-Memory` | Zero-dependency high performance queue | + +--- + +## ๐Ÿ–ฅ๏ธ Operating System Guides + +Choose the dedicated guide for your operating system: + +- [๐ŸชŸ **Windows Setup Guide**](Setup-Windows): Step-by-step setup using PowerShell and `pnpm`. +- [๐ŸŽ **macOS Setup Guide**](Setup-macOS): Installation using Homebrew and OpenJDK 21. +- [๐Ÿง **Linux Setup Guide**](Setup-Linux): Installation for Ubuntu, Debian, Arch Linux, and Fedora/RHEL. +- [๐Ÿ“ **Raspberry Pi Setup Guide**](Setup-Raspberry-Pi): ARM64 Linux setup and performance tuning. +- [๐Ÿณ **Docker Deployment Guide**](Docker-Deployment): Containerized local and server stack deployment. + +--- + +## ๐Ÿš€ Unified Monorepo Launchers + +Master-Bot provides intelligent cross-platform launchers that automatically manage ports, run database schema syncs, spawn services concurrently, and isolate process logs: + +```bash +# Run full development stack (Bot + Dashboard + Lavalink) +pnpm dev + +# Run in production mode +pnpm start +``` diff --git a/wiki/Testing.md b/wiki/Testing.md new file mode 100644 index 000000000..d121959ef --- /dev/null +++ b/wiki/Testing.md @@ -0,0 +1,36 @@ +# ๐Ÿงช Testing & Quality Assurance Guide + +Master-Bot features a comprehensive unit and integration test harness powered by **Vitest v4** and **v8 code coverage**. + +--- + +## ๐Ÿš€ Running Tests + +```bash +# Run Vitest test suites +pnpm test + +# Run tests with code coverage reporting +pnpm run test:coverage + +# Run tests in interactive watch mode +pnpm run test:watch + +# Verify TypeScript types in test suites +pnpm run test:types +``` + +--- + +## ๐Ÿ“Š Monorepo Test Suites Inventory + +| Test Suite | File | Focus Area | +| :--- | :--- | :--- | +| **Config Parity** | `tests/unit/config.test.ts` | Turborepo pipeline, package scripts, parity | +| **Common Utils** | `tests/unit/scripts/common.test.ts` | Launcher path resolution & port extractors | +| **Environment** | `tests/unit/env.test.ts` | Environment variable schema validation | +| **Database** | `tests/unit/db/prisma.test.ts` | `BotDatabase` (node:sqlite) singleton, schema & CRUD | +| **Bot Constants** | `tests/unit/bot/constants.test.ts` | Bot directory paths and module locations | +| **Auth Config** | `tests/unit/auth/auth-config.test.ts` | NextAuth-compatible config from env & session tokens | +| **API Routers** | `tests/unit/api/routers.test.ts` | `dataService` router shapes (playlists, guild, twitch, tickets, reminders) | +| **Dashboard API** | `tests/integration/dashboard-api.test.ts` | Embedded dashboard endpoints over a real HTTP server | diff --git a/wiki/_Footer.md b/wiki/_Footer.md new file mode 100644 index 000000000..b0f8fb378 --- /dev/null +++ b/wiki/_Footer.md @@ -0,0 +1,2 @@ +--- +**Master-Bot Documentation** โ€ข [GitHub Repository](https://github.com/galnir/Master-Bot) โ€ข [Issue Tracker](https://github.com/galnir/Master-Bot/issues) diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md new file mode 100644 index 000000000..e80550f47 --- /dev/null +++ b/wiki/_Sidebar.md @@ -0,0 +1,60 @@ +### [๐Ÿ  Home](Home) + +--- + +### โš™๏ธ Getting Started +- [Overview & Setup](Setup) +- [๐ŸชŸ Windows Setup](Setup-Windows) +- [๐ŸŽ macOS Setup](Setup-macOS) +- [๐Ÿง Linux Setup](Setup-Linux) +- [๐Ÿ“ Raspberry Pi](Setup-Raspberry-Pi) +- [๐Ÿณ Docker Deployment](Docker-Deployment) + +--- + +### โ˜๏ธ Cloud Hosting +- [Hosting Overview](Hosting) +- [๐Ÿš€ Render](Hosting-Render) +- [๐Ÿš† Railway](Hosting-Railway) +- [โœˆ๏ธ Fly.io](Hosting-Fly-io) +- [๐ŸŸฃ Heroku](Hosting-Heroku) +- [๐ŸŸข Koyeb](Hosting-Koyeb) +- [๐Ÿ”ท Northflank](Hosting-Northflank) +- [๐Ÿง Linux VPS](Hosting-VPS) +- [๐Ÿฆ… Pterodactyl](Hosting-Pterodactyl) + +--- + +### ๐ŸŽต Lavalink & Audio +- [Audio Overview](Lavalink) +- [Server Configuration](Lavalink-Configuration) +- [YouTube Device OAuth](Lavalink-YouTube-OAuth) +- [Audio DSP Filters](Lavalink-Audio-Filters) +- [Node Topologies](Lavalink-Nodes) + +--- + +### ๐ŸŒ Web Dashboard +- [Dashboard Overview](Dashboard) +- [Technical Architecture](Dashboard-Architecture) +- [Feature Studios Guide](Dashboard-Studios) + +--- + +### ๐Ÿ”‘ Configuration +- [Environment Variables](Configuration) +- [API Keys & Integrations](API-Keys) + +--- + +### ๐Ÿ“œ Commands Reference +- [Commands Overview](Commands) +- [๐ŸŽต Music Commands](Commands-Music) +- [๐Ÿ”จ Moderation Commands](Commands-Moderation) +- [๐ŸŽฎ Utility & Games](Commands-Utility) +- [โš™๏ธ Server Settings (/set)](Commands-Server-Settings) + +--- + +### ๐Ÿงช Quality & Tests +- [Vitest Test Suite](Testing)