diff --git a/.dockerignore b/.dockerignore index 1308c228b..415aacbb8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,7 +11,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..51f817e86 100644 --- a/.env.example +++ b/.env.example @@ -1,34 +1,49 @@ # DB URL -DATABASE_URL="postgresql://john:doe@localhost:5432/master-bot?schema=public" +DATABASE_URL="file:./db.sqlite" # SQLite database file +# SHADOW_DB_URL is not used with SQLite # Bot Token -DISCORD_TOKEN="" +DISCORD_TOKEN="" # Discord bot token from the Developer Portal -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" +# NextAuth Configuration +NEXTAUTH_SECRET="youshallnotpass" # Random 32+ char secret for signing auth session tokens +NEXTAUTH_URL="" # Canonical public dashboard URL (e.g. https://domain.com) +NEXTAUTH_URL_INTERNAL="http://localhost:3000" # Internal SSR URL for local dashboard requests +NEXT_PUBLIC_INVITE_URL="https://discord.com/api/oauth2/authorize?client_id=your_client_id&permissions=8&scope=bot" # Public OAuth2 bot invite link # Next Auth Discord Provider -DISCORD_CLIENT_ID="" -DISCORD_CLIENT_SECRET="" +DISCORD_CLIENT_ID="" # Discord application client ID +DISCORD_CLIENT_SECRET="" # Discord application client secret # Lavalink -LAVA_HOST="0.0.0.0" -LAVA_PASS="youshallnotpass" -LAVA_PORT=2333 -LAVA_SECURE=false +LAVA_HOST="localhost" # Lavalink host (default: localhost or 0.0.0.0) +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 -SPOTIFY_CLIENT_ID="" -SPOTIFY_CLIENT_SECRET="" +SPOTIFY_CLIENT_ID="" # Spotify Developer App Client ID +SPOTIFY_CLIENT_SECRET="" # Spotify Developer App Client Secret -# Twitch -TWITCH_CLIENT_ID="" -TWITCH_CLIENT_SECRET="" +# Twitch & IGDB +TWITCH_CLIENT_ID="" # Twitch Developer App Client ID (used for Twitch alerts & IGDB search) +TWITCH_CLIENT_SECRET="" # Twitch Developer App Client Secret # Other APIs -TENOR_API="" -NEWS_API="" -GENIUS_API="" -RAWG_API="" +KLIPY_API="" # API key for anime reactions and interactive GIFs +NEWS_API="" # NewsAPI key for /world-news global headline searches +GENIUS_API="" # Genius API client token for /lyrics song lyrics lookup + +# Feature Flags (Enable or disable specific bot modules dynamically) +LAVA_ENABLED=true # Master toggle for Lavalink audio engine and music commands +GIFS_ENABLED=true # Toggle for animated GIF and reaction commands +TWITCH_ENABLED=true # Toggle for Twitch stream monitoring and notifications +NEWS_ENABLED=true # Toggle for news headline commands +IGDB_ENABLED=true # Toggle for IGDB game database lookups 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..a959b5234 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 @@ -41,17 +56,13 @@ node_modules # Debug logs -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* +*.log +*.txt # Legacy -db.sqlite -db.sqlite-journal +*.sqlite +*-journal */.pnp .pnp.js -logs config.json -test.js -json.sqlite \ No newline at end of file +test.js \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..1fcf8573b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,229 @@ +# 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` | Next.js 15 (App Router), Tailwind CSS, tRPC v11, React Query v5 | Web dashboard, server settings studios, audit-log & telemetry views | +| **`@master-bot/auth`** | `packages/auth` | NextAuth.js v5 beta, `@auth/prisma-adapter` | Discord OAuth authentication, session validation, user upsert by Discord ID | +| **`@master-bot/db`** | `packages/db` | Prisma ORM v5, SQLite | Schema definitions, typed client instance, zero-ops database file | +| **`@master-bot/config`** | `packages/config`| ESLint, Tailwind presets | Shared lint & design tooling for workspaces | +| **`Launcher Scripts`** | `scripts/` | Node.js ESM (`.mjs`), child processes | Cross-platform dev & prod orchestration, port cleanup, log routing | + +### Runtime State + +The bot keeps all runtime state in an in-memory **`SessionManager`** (`apps/bot/src/lib/session`), hydrating from and persisting to SQLite through Prisma. There is no separate API package or database server: the dashboard shares the same Prisma client and `db.sqlite` as the bot. + +--- + +## ๐Ÿ› ๏ธ Prerequisites & Development Setup + +### System Requirements + +- **Node.js**: `>=20.0.0` +- **pnpm**: `>=8.0.0` (`npm install -g pnpm`) +- **Java**: Java 17 or higher (Java 21 LTS recommended) โ€” only for a local Lavalink v4 (music) +- **Database**: None โ€” SQLite (`db.sqlite`) is created automatically on install + +### Setup Steps + +1. **Fork and Clone the Repository**: + + ```bash + git clone https://github.com//Master-Bot.git + cd Master-Bot + ``` + +2. **Install Dependencies** (creates & migrates the SQLite schema): + + ```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 + - `NEXTAUTH_SECRET`: Random 32+ character signing secret + - `NEXTAUTH_URL`: Dashboard URL (e.g. `http://localhost:3000`) + - `LAVA_ENABLED`: Set to `true` if you wish to run and test audio playback. + + See the [Configuration Wiki](wiki/Configuration.md) for every optional key and feature flag. + +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 bot, dashboard, and optionally Lavalink, with a combined status console and logs written 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 the bot +pnpm --filter @master-bot/bot type-check + +# Type-check / build the dashboard +pnpm --filter @master-bot/dashboard type-check + +# Full workspace build +pnpm build + +# Lint + monorepo consistency check +pnpm lint +``` + +--- + +## ๐Ÿ“ 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. +- **Session-Access Pattern**: Read and mutate state via `client.session` (the `SessionManager`) โ€” methods are synchronous. Never reach for a separate API layer or raw Prisma calls inside commands. +- **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 Standards (`apps/dashboard`) + +- **React Server vs. Client Components**: Clearly delineate CSR vs. SSR boundaries in Next.js 15 (`'use client'` at the top of interactive components). +- **Type-Safe API**: Studio mutations go through the tRPC layer with Zod validation; render from hydrated session data where possible. +- **Tailwind CSS**: Use consistent utility classes adhering to the dark-mode palette and design system. + +### 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/`, `db.sqlite`) 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`, `auth`, `db`, `music`, `moderation`, `tickets`, `settings`, `session`, `launcher`, `deps` + +#### Examples + +- `feat(music): add live 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`, `logs/dashboard.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! ๐Ÿš€ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 30e1811e6..b17f315fc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM --platform=linux/amd64 node:18-slim +FROM --platform=linux/amd64 node:20-slim ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" ENV NEXT_TELEMETRY_DISABLED 1 @@ -12,14 +12,13 @@ ENV PORT 3000 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 + 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 # 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" # ENV LAVA_HOST="host.docker.internal" 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..11eb9b164 100644 --- a/README.md +++ b/README.md @@ -1,260 +1,179 @@ -# A Discord Music Bot written in TypeScript using Sapphire, discord.js, Next.js and React - -[![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/) - -## 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) - -## Setup bot - -Create an [application.yml](https://github.com/freyacodes/lavalink/blob/master/LavalinkServer/application.yml.example) file 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. - -### PostgreSQL - -#### Linux - -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). - -#### 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. - -```env -# 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="" - +# ๐Ÿค– Master-Bot + +[![TypeScript](https://img.shields.io/badge/Language-TypeScript-blue.svg)](https://www.typescriptlang.org) +[![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20.0.0-green.svg)](https://nodejs.org/) +[![pnpm](https://img.shields.io/badge/Package_Manager-pnpm-orange.svg)](https://pnpm.io/) +[![Lavalink](https://img.shields.io/badge/Lavalink-v4.x-purple.svg)](https://github.com/lavalink-devs/Lavalink) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/galnir/Master-Bot/pulls) + +**Master-Bot** is a production-ready, high-performance Discord Music and Utility Bot with a full-featured **Next.js Web Dashboard**. Built with **TypeScript**, **Sapphire Framework**, **discord.js v14**, **Next.js 15**, **tRPC v11**, **Prisma ORM** (SQLite), and **Lavalink v4**. + +--- + +## ๐Ÿ—๏ธ Project Architecture & Structure + +Master-Bot is organized as a Turborepo workspace managed with `pnpm`: + +```text +Master-Bot/ +โ”œโ”€โ”€ apps/ +โ”‚ โ”œโ”€โ”€ bot/ # Sapphire & Discord.js v14 Bot Application +โ”‚ โ””โ”€โ”€ dashboard/ # Next.js 15 Web Dashboard (Tailwind CSS, NextAuth, tRPC) +โ”œโ”€โ”€ packages/ +โ”‚ โ”œโ”€โ”€ auth/ # Shared NextAuth.js (Discord OAuth) Configuration +โ”‚ โ”œโ”€โ”€ config/ # Shared Tooling Config (eslint/, tailwind/) +โ”‚ โ””โ”€โ”€ db/ # Shared Prisma ORM Client & SQLite Schema +โ”œโ”€โ”€ scripts/ +โ”‚ โ”œโ”€โ”€ common.mjs # Shared cross-platform port management & log writers +โ”‚ โ”œโ”€โ”€ dev.mjs # Unified Development Launcher & Service Manager +โ”‚ โ””โ”€โ”€ start.mjs # Unified Production Launcher & Service Manager +โ”œโ”€โ”€ wiki/ # Project documentation (Setup, Configuration, Commands) +โ”œโ”€โ”€ logs/ # Service-specific log files (bot.log, dashboard.log, lavalink.log) +โ”œโ”€โ”€ packages/db/prisma/ # Prisma schema + db.sqlite (auto-created on install) +โ”œโ”€โ”€ application.yml.example # Lavalink v4 Configuration Template (copy to application.yml) +โ”œโ”€โ”€ Dockerfile # Containerized single-service deployment +โ””โ”€โ”€ docker-compose.yml # Stack orchestration helpers (legacy; see the Wiki) ``` -#### 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. - -#### DB URL - -Change 'john' to your pc username and 'doe' to some password, or set the name and password you created when you installed Postgres. +> ๐Ÿ”„ **Note:** the project has migrated from a managed database server to **SQLite**. `docker-compose.yml` and the launcher helpers still contain some legacy service wiring that hasn't been migrated yet โ€” for accurate deployment today, follow the [Deployment Wiki](wiki/Deployment.md). -#### Bot Token +--- -Generate a token in your Discord developer portal. - -#### Next Auth - -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/). - -#### Next Auth Discord Provider +## โšก Key Features -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': +- **๐ŸŽต High-Performance Audio Engine:** Powered by **Lavalink v4** with support for YouTube (multi-client + OAuth), Spotify metadata resolution (`lavasrc-plugin`), free built-in SoundCloud, Twitch, Vimeo, and direct audio streams. Includes interactive channel player embeds with real-time progress bars and audio filters (`/bassboost`, `/karaoke`, `/nightcore`, `/vaporwave`). +- **๐Ÿ“š Custom Playlists:** Per-user, per-server playlists via `/create-playlist`, `/save-to-playlist`, `/my-playlists`, `/display-playlist`, `/delete-playlist`, and `/remove-from-playlist`. +- **๐Ÿ”จ Full Moderation Suite:** Dedicated slash commands (`/ban`, `/kick`, `/slowmode`, `/timeout`, `/purge`) with permission hierarchy validation and safety checks. +- **๐ŸŽซ Thread-Based Support Ticket System:** Interactive ticket panel, thread management, a configurable manager role, and `.txt` transcript archiving. +- **๐Ÿ“œ Granular Audit Logging:** 20 event triggers across members, messages, channels, roles, voice, and moderation โ€” tuned per server via `/set` or the dashboard. +- **๐Ÿ—„๏ธ Zero-Ops Database:** SQLite via Prisma. The schema is generated and pushed automatically on `pnpm install`; no database server to install or manage. +- **๐Ÿ”‘ Native YouTube Device-Flow OAuth:** `/youtube-auth` authorizes a streaming account; the refresh token persists to `.youtube-oauth.json` without rewriting `.env`. +- **๐ŸŒ Interactive Web Dashboard:** Next.js 15 App Router command center โ€” per-server studios for welcome messages, audit logs, tickets, reminders, per-command toggles, music, broadcasts, integrations, and system telemetry. +- **๐ŸŽฏ Feature Flags:** Individual bot modules (Lavalink audio, GIFs, Twitch, News, IGDB) can be enabled or disabled via environment variables. +- **๐Ÿš€ Cross-Platform Unified Launchers:** `pnpm dev` and `pnpm start` manage ports, route output to isolated log files (`logs/`), and present a clean console status UI. +- **๐Ÿ–ผ๏ธ Reaction GIFs & Media:** Powered by Klipy API and Waifu.im (`/gif`, `/hug`, `/waifu`, `/cat`, `/doggo`, and more). +- **๐ŸŽฎ Gaming & Info:** Live Twitch channel alerts, IGDB game search, TVMaze TV show info, and a suite of fun utilities (`/8ball`, `/urban`, `/trump`, `/kanye`, `/translate`, and more). -- http://localhost:3000/api/auth/callback/discord -- http://domain:3000/api/auth/callback/discord +--- -Make sure to change 'domain' in http://domain:3000/api/auth/callback/discord to your domain or public ip. +## ๐Ÿ“‹ System Requirements -#### Lavalink +- **Node.js**: `>=20.0.0` +- **pnpm**: `>=8.0.0` (`npm install -g pnpm`) +- **Java**: Java 17+ (21 LTS recommended) โ€” only required for a **local Lavalink** server (music) +- **Database**: None โ€” SQLite file (`db.sqlite`) is created automatically -You can leave this as long as the values match your application.yml. +--- -#### Spotify and Twitch +## ๐Ÿš€ Quick Start Guide -Create an application in each platform's developer portal and paste the relevant values. +### 1. Clone & Install Dependencies -#### 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` - -# Running the bot +```bash +git clone https://github.com/galnir/Master-Bot.git +cd Master-Bot +pnpm install +``` -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! +`pnpm install` generates the Prisma client and creates the SQLite database (`db.sqlite`). -# Commands +### 2. Configure Environment Variables -A full list of commands for use with Master Bot +Create `.env` in the workspace root from `.env.example`: -## Music +```bash +cp .env.example .env +``` -| Command | Description | Usage | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -| /play | Play any song or playlist from youtube, you can do it by searching for a song by name or song url or playlist url | /play darude sandstorm | -| /pause | Pause the current playing song | /pause | -| /resume | Resume the current paused song | /resume | -| /leave | Leaves voice channel if in one | /leave | -| /remove | Remove a specific song from queue by its number in queue | /remove 4 | -| /queue | Display the song queue | /queue | -| /shuffle | Shuffle the song queue | /shuffle | -| /skip | Skip the current playing song | /skip | -| /skipall | Skip all songs in queue | /skipall | -| /skipto | Skip to a specific song in the queue, provide the song number as an argument | /skipto 5 | -| /volume | Adjust song volume | /volume 80 | -| /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 | -| /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' | +Fill in your mandatory credentials: -## Gifs +- `DISCORD_TOKEN`: Bot token from the Discord Developer Portal +- `DISCORD_CLIENT_ID` & `DISCORD_CLIENT_SECRET`: Application OAuth2 credentials +- `NEXTAUTH_SECRET`: Random 32+ character signing secret +- `NEXTAUTH_URL`: Public dashboard URL (e.g. `http://localhost:3000`) -| Command | Description | Usage | -| ---------- | -------------------------- | ---------- | -| /gif | Get a random gif | /gif | -| /jojo | Get a random jojo gif | /jojo | -| /gintama | Get a random gintama gif | /gintama | -| /anime | Get a random anime gif | /anime | -| /baka | Get a random baka gif | /baka | -| /cat | Get a cute cat picture | /cat | -| /doggo | Get a cute dog picture | /doggo | -| /hug | Get a random hug gif | /hug | -| /slap | Get a random slap gif | /slap | -| /pat | Get a random pat gif | /pat | -| /triggered | Get a random triggered gif | /triggered | -| /amongus | Get a random Among Us gif | /amongus | +Optional audio/feature keys (Spotify, YouTube, Twitch, News, Genius, Klipy) and the `LAVA_*` + feature-flag variables are documented in the [Configuration Wiki](wiki/Configuration.md). -## Other +### 3. Run the Stack -| Command | Description | Usage | -| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------- | -| /fortune | Get a fortune cookie tip | /fortune | -| /insult | Generate an evil insult | /insult | -| /chucknorris | Get a satirical fact about Chuck Norris | /chucknorris | -| /motivation | Get a random motivational quote | /motivation | -| /random | Generate a random number between two provided numbers | /random 0 100 | -| /8ball | Get the answer to anything! | /8ball Is this bot awesome? | -| /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 | -| /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 | +```bash +pnpm dev +``` -## Resources +Starts the bot, dashboard, and (when `LAVA_ENABLED=true` and Java is present) a local Lavalink server with a unified status console and `logs/`. For production: `pnpm build && pnpm start`. -[Getting a Tenor API key](https://developers.google.com/tenor/guides/quickstart) +--- -[Getting a NewsAPI API key](https://newsapi.org/) +## ๐ŸŽต YouTube OAuth Setup -[Getting a Genius API key](https://genius.com/api-clients/new) +1. Run `/youtube-auth` in Discord (or the terminal device-flow prompt at first launch). +2. Open the returned URL, log in with the YouTube account you want to stream through, and approve the scopes. +3. The bot stores the refresh token atomically in `.youtube-oauth.json` and keeps a `YOUTUBE_REFRESH_TOKEN` binding for Lavalink. -[Getting a rawg API key](https://rawg.io/apidocs) +Authorized playback defeats YouTube throttling/blocking. See [Music & Lavalink](wiki/Music.md#youtube-oauth). -[Getting a Twitch API key](https://github.com/Bacon-Fixation/Master-Bot/wiki/Getting-Your-Twitch-API-Info) +--- -[Installing Node.js on Debian](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-debian-9) +## ๐Ÿ“– Available Commands -[Installing Node.js on Windows](https://treehouse.github.io/installation-guides/windows/node-windows.html) +> Master-Bot ships with **74 slash commands** across Music, Moderation, GIFs, Games, Utilities, News, and Reminders. For the complete, up-to-date list and the `/set` subcommands, see the [Commands Reference](wiki/Commands.md). -[Installing on a Raspberry Pi](https://github.com/galnir/Master-Bot/wiki/Running-the-bot-on-a-Raspberry-Pi) +| Category | Highlights | +| --- | --- | +| ๐ŸŽต **Music** | `/play`, `/queue`, `/shuffle`, `/jump`, `/seek`, `/volume`, `/lyrics`, `/bassboost`, `/music-trivia`, playlists, `/youtube-auth` | +| ๐Ÿ”จ **Moderation** | `/ban`, `/kick`, `/timeout`, `/slowmode`, `/purge` | +| โš™๏ธ **Utility** | `/set`, `/help`, `/reminder`, `/poll`, `/weather`, `/translate`, `/world-news`, `/8ball`, `/reddit`, `/urban` | +| ๐ŸŽฎ **Games** | `/connect-four`, `/tic-tac-toe`, `/rockpaperscissors`, `/game-search` | +| ๐Ÿ˜‚ **GIFs** | `/gif`, `/hug`, `/waifu`, `/cat`, `/doggo`, `/slap`, and more | +| ๐ŸŸฃ **Twitch** | `/twitch-status` + live stream alerts via `/set twitch` | -[Using a Repl.it LavaLink server](https://github.com/galnir/Master-Bot/wiki/Setting-Up-LavaLink-with-a-Replit-server) +--- -[Using a public LavaLink server](https://github.com/galnir/Master-Bot/wiki/Setting-Up-LavaLink-with-a-public-LavaLink-Server) +## ๐Ÿณ Docker Deployment -[Using an Internal LavaLink server](https://github.com/galnir/Master-Bot/wiki/Setting-up-LavaLink-with-an-Internal-LavaLink-server) +A portable**Dockerfile** (`node:20-slim`, port `3000`) is included. For single-service container deployment, a cloud walkthrough, and persistence guidance, see [Deployment Wiki](wiki/Deployment.md). -## Contributing +--- -Fork it and submit a pull request! -Anyone is welcome to suggest new features and improve code quality! +## ๐Ÿ“š Documentation & Wiki -## Contributors โค๏ธ +Visit the [Wiki](wiki/Home.md) for full documentation: -**โญ [Bacon Fixation](https://github.com/Bacon-Fixation) โญ - Countless contributions** +- ๐Ÿš€ [Getting Started](wiki/Getting-Started.md) +- โš™๏ธ [Configuration & API Keys](wiki/Configuration.md) +- ๐Ÿ—๏ธ [Architecture & Database](wiki/Architecture.md) +- โŒจ๏ธ [Commands Reference](wiki/Commands.md) +- ๐ŸŽต [Music & Lavalink](wiki/Music.md) +- ๐ŸŒ [Web Dashboard](wiki/Dashboard.md) +- ๐Ÿš€ [Deployment & Cloud Hosting](wiki/Deployment.md) +- โ“ [FAQ & Troubleshooting](wiki/FAQ.md) -[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 +## ๐Ÿ‘ฅ Contributors โค๏ธ -[Natemo6348](https://github.com/Natemo6348) - 'mute', 'unmute' +> โญ **Bacon Fixation** โ€” countless contributions across the project. -[kfirmeg](https://github.com/kfirmeg) - play command flags, dockerization, docker wiki +| Contributor | Contributions | +| --- | --- | +| [ModoSN](https://github.com/ModoSN) | `resolve-ip`, `rps`, `8ball`, `bored`, `trump`, `advice`, `kanye`, `urban dictionary` commands and visual updates | +| [PhantomNimbi](https://github.com/PhantomNimbi) | GIF commands, Lavalink v4 engine, Next.js 15 migration, moderation suite, support ticket system, live ASCII progress bar & auto-updater | +| [rafaeldamasceno](https://github.com/rafaeldamasceno) | `music-trivia` and Dockerfile improvements | +| [navidmafi](https://github.com/navidmafi) | `LeaveTimeOut` and `MaxResponseTime` options, update issue template, fix leave command | +| [Kyoyo](https://github.com/NotKyoyo) | brought back `now-playing` | +| [MontejoJorge](https://github.com/MontejoJorge) | brought back `remind` | +| [malokdev](https://github.com/malokdev) | `uptime` command | +| [chimaerra](https://github.com/chimaerra) | minor command tweaks | -[rafaeldamasceno](https://github.com/rafaeldamasceno) - 'music-trivia' and Dockerfile improvements, minor tweaks +--- -[navidmafi](https://github.com/navidmafi) - 'LeaveTimeOut' and 'MaxResponseTime' options, update issue template, fix leave command +## ๐Ÿค Contributing -[Kyoyo](https://github.com/NotKyoyo) - added back 'now-playing' +We welcome contributions of all kinds! Please read our [Contributing Guidelines](CONTRIBUTING.md) to get started with local setup, coding standards, and pull request workflows. -[MontejoJorge](https://github.com/MontejoJorge) - added back 'remind' +--- -[malokdev](https://github.com/malokdev) - 'uptime' command +## ๐Ÿ“„ License -[chimaerra](https://github.com/chimaerra) - minor command tweaks +Distributed under the MIT License. See [`LICENSE.md`](LICENSE.md) for more information. \ No newline at end of file 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..3aa70c625 --- /dev/null +++ b/apps/bot/README.md @@ -0,0 +1,83 @@ +# ๐Ÿค– 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/), [Lavalink v4 (`lavalink-client`)](https://github.com/lavalink-devs/Lavalink), and [Prisma ORM](https://www.prisma.io/) (SQLite). + +--- + +## ๐Ÿ—๏ธ Architecture & Directory Structure + +```text +apps/bot/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ index.ts # Boot: session.init() โ†’ client.login() +โ”‚ โ”œโ”€โ”€ 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, /set +โ”‚ โ”‚ โ””โ”€โ”€ twitch/ # Twitch status monitor +โ”‚ โ”œโ”€โ”€ lib/ # Internal business logic and class modules +โ”‚ โ”‚ โ”œโ”€โ”€ session/ # SessionManager โ€” in-memory state hub (SQLite-backed) +โ”‚ โ”‚ โ”œโ”€โ”€ set/ # Per-feature /set subcommand handlers (welcome, logging, ticketsโ€ฆ) +โ”‚ โ”‚ โ”œโ”€โ”€ games/ # Connect 4, Tic-Tac-Toe, Rock-Paper-Scissors +โ”‚ โ”‚ โ”œโ”€โ”€ gifs/ # Media scrapers & fetchers +โ”‚ โ”‚ โ”œโ”€โ”€ music/ # Queue, QueueStore, TriviaSession, NowPlaying embeds, YouTube OAuth +โ”‚ โ”‚ โ”œโ”€โ”€ presence/ # Dynamic rotating presence status manager +โ”‚ โ”‚ โ”œโ”€โ”€ reminders/ # Background reminder scheduler (30s tick) +โ”‚ โ”‚ โ”œโ”€โ”€ structures/ # ExtendedClient, CommandHelp, HelpRegistry +โ”‚ โ”‚ โ””โ”€โ”€ twitch/ # Twitch token and live stream checkers +โ”‚ โ”œโ”€โ”€ listeners/ # Sapphire event listeners +โ”‚ โ”‚ โ”œโ”€โ”€ guild/ # Guild member add/remove, guild create/delete +โ”‚ โ”‚ โ”œโ”€โ”€ interaction/ # Ticket buttons, slash command errors +โ”‚ โ”‚ โ”œโ”€โ”€ music/ # Lavalink node connection and track lifecycle events +โ”‚ โ”‚ โ””โ”€โ”€ tempchannels/ # Temporary voice channel lifecycle management +โ”‚ โ”œโ”€โ”€ preconditions/ # Sapphire preconditions (isCommandDisabled, permissions) +โ”‚ โ””โ”€โ”€ env.ts # Type-safe environment validation (zod) +โ”œโ”€โ”€ package.json +โ””โ”€โ”€ tsconfig.json +``` + +--- + +## โšก Key Features & Subsystems + +1. **๐ŸŽต Lavalink v4 Audio Playback**: + - YouTube multi-client failover with `/youtube-auth` OAuth token capture (persisted to `.youtube-oauth.json`). + - Spotify metadata resolution via `lavasrc-plugin`. + - Free built-in SoundCloud track search and playback. + - Interactive channel now-playing embeds with live progress bars. + - Audio DSP filters: Bassboost, Karaoke, Nightcore, Vaporwave. + - Per-user, per-server custom playlists (`Playlist`/`Song` models). +2. **๐Ÿ”จ Moderation Suite**: + - Slash commands with hierarchy safety checks and automated audit logging. +3. **๐ŸŽซ Support Tickets**: + - Thread-based ticketing system with interactive panels and `.txt` transcript archiving. +4. **โฐ Scheduled Reminders**: + - Background scheduler checking reminders every 30 seconds; per-guild scoping. +5. **๐Ÿ“œ Audit Logging**: + - 20 granular server event triggers routing formatted embeds to designated log channels. +6. **๐Ÿง  Session Persistence**: + - All runtime state (guilds, welcome messages, tickets, playlists, reminders, Twitch subscriptions, members) lives in the in-memory `SessionManager`, hydrates from SQLite at boot, and persists writes through a serial queue. +7. **๐Ÿ‘‹ Member Lifecycle**: + - Per-guild `GuildMember` rows on join; cascade-cleanup of tickets, temp channels, playlists, reminders, and Twitch data on leave. + +--- + +## ๐Ÿš€ 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 +pnpm --filter @master-bot/bot dev + +# Launch full development stack (Bot + Dashboard + Lavalink) +pnpm dev +``` + +## ๐Ÿ“š Wiki + +See [Music & Lavalink](https://github.com/galnir/Master-Bot/wiki/Music) and the [Commands Reference](https://github.com/galnir/Master-Bot/wiki/Commands) for feature documentation. \ No newline at end of file diff --git a/apps/bot/package.json b/apps/bot/package.json index 2e4b3ab65..ad632bd14 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -9,60 +9,53 @@ "scripts": { "build": "pnpm with-env tsc", "watch": "tsc --watch", - "copy-scripts": "pnpx ncp ./scripts ./dist/", + "copy-scripts": "ncp ./scripts ./dist/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 --" }, "engines": { - "node": ">=v18.16.1" + "node": ">=20.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", + "@napi-rs/canvas": "^1.0.8", + "@prisma/client": "^5.22.0", + "@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", "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", + "ioredis": "^5.6.1", + "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", "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", + "zod": "^3.24.4" }, "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": "^20.19.43", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "dotenv": "^16.6.1", + "dotenv-cli": "^7.4.4", + "prettier": "^3.9.6", + "tslib": "^2.8.1", + "typescript": "^5.9.3" }, "eslintConfig": { "root": true, diff --git a/apps/bot/src/commands/gifs/amongus.ts b/apps/bot/src/commands/gifs/amongus.ts index a910fc2d6..cea54cd9c 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'; 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'; @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..34b0a2a5e 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'; 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'; @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..363ad1b64 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'; 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'; @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..683e37efc 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'; 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'; @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..da7a16474 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'; 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'; @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..0a3c258d9 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'; 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'; @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..45ec8c8b6 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'; 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'; @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..0891a80b5 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'; 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'; @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..3a7956d81 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'; 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'; @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..cfc4b7f87 --- /dev/null +++ b/apps/bot/src/commands/gifs/pat.ts @@ -0,0 +1,67 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { searchGif } from '../../lib/gifs/searchGif'; + +@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..554989e40 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'; 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'; @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..9ffe80e82 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'; 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'; @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..82f3546af --- /dev/null +++ b/apps/bot/src/commands/moderation/ban.ts @@ -0,0 +1,209 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +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..46c16ea8e --- /dev/null +++ b/apps/bot/src/commands/moderation/kick.ts @@ -0,0 +1,192 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +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..5c6600772 --- /dev/null +++ b/apps/bot/src/commands/moderation/purge.ts @@ -0,0 +1,134 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +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..926859a34 --- /dev/null +++ b/apps/bot/src/commands/moderation/slowmode.ts @@ -0,0 +1,148 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +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..13a31309b --- /dev/null +++ b/apps/bot/src/commands/moderation/timeout.ts @@ -0,0 +1,228 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +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..93d8053d4 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'; 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..bd3fcea41 100644 --- a/apps/bot/src/commands/music/create-playlist.ts +++ b/apps/bot/src/commands/music/create-playlist.ts @@ -1,6 +1,6 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; -import { trpcNode } from '../../trpc'; @ApplyOptions({ name: 'create-playlist', @@ -33,31 +33,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({ + this.container.client.session.playlists.create({ name: playlistName, + guildId: interaction.guildId ?? '', 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..5023766a8 100644 --- a/apps/bot/src/commands/music/delete-playlist.ts +++ b/apps/bot/src/commands/music/delete-playlist.ts @@ -1,6 +1,6 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; -import { trpcNode } from '../../trpc'; import Logger from '../../lib/logger'; @ApplyOptions({ @@ -35,31 +35,50 @@ 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 = this.container.client.session.playlists.delete({ name: playlistName, + guildId: interaction.guildId ?? '', 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..416e7d944 100644 --- a/apps/bot/src/commands/music/display-playlist.ts +++ b/apps/bot/src/commands/music/display-playlist.ts @@ -1,8 +1,8 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; 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'; @ApplyOptions({ name: 'display-playlist', @@ -36,32 +36,32 @@ 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 { playlist } = this.container.client.session.playlists.getPlaylist({ name: playlistName, + guildId: interaction.guildId ?? '', userId: interactionMember.id }); - 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 +76,19 @@ 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..56fbbe083 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'; 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..102a36f65 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'; 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..87b1c474b 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'; 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..a9befa4f5 100644 --- a/apps/bot/src/commands/music/lyrics.ts +++ b/apps/bot/src/commands/music/lyrics.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -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..588268f17 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'; 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..8d0764bb3 --- /dev/null +++ b/apps/bot/src/commands/music/music-trivia.ts @@ -0,0 +1,117 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { TriviaSession } from '../../lib/music/classes/TriviaSession'; +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..b9b088995 100644 --- a/apps/bot/src/commands/music/my-playlists.ts +++ b/apps/bot/src/commands/music/my-playlists.ts @@ -1,18 +1,13 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; 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'; @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,31 +22,33 @@ 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 { playlists } = this.container.client.session.playlists.getAll({ + guildId: interaction.guildId ?? '', userId: interactionMember.id }); - if (!playlistsQuery || !playlistsQuery.playlists.length) { - return await interaction.reply(':x: You have no custom playlists'); + if (!playlists.length) { + return await interaction.editReply(':x: You have no custom playlists'); } new PaginatedFieldMessageEmbed() .setTitleField('Custom Playlists') .setTemplate(baseEmbed) - .setItems(playlistsQuery.playlists) + .setItems(playlists) .formatItems((playlist: any) => playlist.name) .setItemsPerPage(5) .make() @@ -60,3 +57,13 @@ 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..3fcab1d63 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'; 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..bbc1c453a 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'; 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..2dc8bf095 100644 --- a/apps/bot/src/commands/music/play.ts +++ b/apps/bot/src/commands/music/play.ts @@ -1,9 +1,10 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; 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 { updatePlayerEmbed } from '../../lib/music/buttonHandler'; +import { Song } from '../../lib/music/classes/Song'; import { GuildMember } from 'discord.js'; @ApplyOptions({ @@ -68,7 +69,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 +89,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 +98,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,58 +106,87 @@ 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 { playlist } = client.session.playlists.getPlaylist({ userId: interactionMember.id, + guildId: interaction.guildId ?? '', name: query }); - 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..7f1e7b3ac 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'; 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..2c5c1f485 100644 --- a/apps/bot/src/commands/music/remove-from-playlist.ts +++ b/apps/bot/src/commands/music/remove-from-playlist.ts @@ -1,6 +1,6 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; -import { trpcNode } from '../../trpc'; @ApplyOptions({ name: 'remove-from-playlist', @@ -49,46 +49,72 @@ 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({ - name: playlistName, - userId: interactionMember.id - }); + const { playlist: foundPlaylist } = + this.container.client.session.playlists.getPlaylist({ + name: playlistName, + guildId: interaction.guildId ?? '', + userId: interactionMember.id + }); - playlist = playlistQuery.playlist; + playlist = foundPlaylist; } 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({ - id - }); - - if (!song) { - return await interaction.followUp(':x: Something went wrong!'); + let song; + try { + ({ song } = this.container.client.session.songs.delete({ + id + })); + } catch { + return await interaction.editReply(':x: Something went wrong!'); } - await interaction.followUp( - `:wastebasket: Deleted **${song.song.title}** from **${playlistName}**` + await interaction.editReply( + `:wastebasket: Deleted **${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..c862548be 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'; 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..6f455e0a2 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'; 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..9f7457394 100644 --- a/apps/bot/src/commands/music/save-to-playlist.ts +++ b/apps/bot/src/commands/music/save-to-playlist.ts @@ -1,7 +1,7 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; 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'; @ApplyOptions({ @@ -49,48 +49,78 @@ 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 { playlist } = this.container.client.session.playlists.getPlaylist({ name: playlistName, + guildId: interaction.guildId ?? '', userId: interactionMember.id }); - if (!playlistQuery.playlist) { - return await interaction.followUp('Playlist does not exist'); + if (!playlist) { + return await interaction.editReply('Playlist does not exist'); } - const playlistId = playlistQuery.playlist.id; + const playlistId = 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({ + this.container.client.session.songs.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..45262e24a 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'; 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..22a1f3291 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'; 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..ea5ac9128 --- /dev/null +++ b/apps/bot/src/commands/music/stop-trivia.ts @@ -0,0 +1,48 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +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..48e3ae97c 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'; 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..8990a54d1 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'; 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..762e774b7 --- /dev/null +++ b/apps/bot/src/commands/music/youtube-auth.ts @@ -0,0 +1,99 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +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..c72fc30f8 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'; 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..dacfed351 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'; 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..b6af2718f 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'; 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..6be45744d 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'; 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..c95211024 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'; 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..b44903540 --- /dev/null +++ b/apps/bot/src/commands/other/bored.ts @@ -0,0 +1,304 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +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..1ee77b53f 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'; 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..8f2770dec --- /dev/null +++ b/apps/bot/src/commands/other/connect-four.ts @@ -0,0 +1,183 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { Connect4Game } from '../../lib/games/connect-4'; +import { GameInvite } from '../../lib/games/inviteEmbed'; +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..05a506f79 --- /dev/null +++ b/apps/bot/src/commands/other/dashboard.ts @@ -0,0 +1,80 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { getApplicationOwnerUser } from '../../lib/music/youtubeOAuth'; + +@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 || ''; + + if (!publicUrl && !internalUrl) { + return interaction.reply({ + content: + ':information_source: The dashboard is not configured for this bot instance.', + ephemeral: true + }); + } + + 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 + }); + } + + if (internalUrl) { + const ownerUser = await getApplicationOwnerUser(this.container.client); + if (ownerUser && interaction.user.id === ownerUser.id) { + fields.push({ + name: '๐Ÿ  Internal Link (Owner)', + value: `[Open internal 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..ab0b0089f 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'; 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..367a60572 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'; 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..fda456bc0 100644 --- a/apps/bot/src/commands/other/games.ts +++ b/apps/bot/src/commands/other/games.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; import { TicTacToeGame } from '../../lib/games/tic-tac-toe'; import { Connect4Game } from '../../lib/games/connect-4'; import { GameInvite } from '../../lib/games/inviteEmbed'; @@ -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..599966e60 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'; +import { HelpRegistry } from '../../lib/structures/HelpRegistry'; 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,236 @@ 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(`โšก ${targetHelp.name}`) + .setColor(0x4f46e5) + .setThumbnail(client.user?.displayAvatarURL() || null) + .setDescription(targetHelp.description || 'No description provided.') + .addFields( + { name: '๐Ÿ“‚ Category', value: `${categoryEmoji} ${categoryName}`, inline: true }, + { name: '๐Ÿ’ป Usage', value: `${targetHelp.usage || '/' + targetHelp.name}`, inline: true } + ) + .setFooter({ text: 'Master-Bot โ€ข /help [command]', 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} โ€” ${opt.description}`; + }) + .join('\n'); + + detailEmbed.addFields({ + name: 'โš™๏ธ Options', + value: optionsFormatted, + inline: false }); + } - return PaginatedEmbed.run(interaction); - } else { - const commandMap = new Map(); - sortedList.reduce( - (obj, command) => commandMap.set(command.name, command), - {} - ); - 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 - }); - }); - const DetailedPagination = new PaginatedFieldMessageEmbed(); - - 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` - ); + if (targetHelp.examples && targetHelp.examples.length > 0) { + detailEmbed.addFields({ + name: '๐Ÿ’ก Examples', + value: targetHelp.examples.map(ex => `${ex}`).join('\n'), + inline: false + }); } - interface CommandInfo { - name: string; - options: ApplicationCommandOption[]; - details: string; + + return await interaction.reply({ embeds: [detailEmbed] }); } + + // 2. Full Overview & Dynamic Category Browsing Mode + const categoriesMap = HelpRegistry.getCategoriesMap(); + + const mainEmbed = new EmbedBuilder() + .setTitle('๐Ÿค– Master-Bot Command Center') + .setColor(0x4f46e5) + .setThumbnail(client.user?.displayAvatarURL() || null) + .setDescription( + `Welcome to Master-Bot. Browse by category below or type /help [command] for details.` + ) + .setFooter({ + text: 'Select a category below to view commands โ€ข Master-Bot', + iconURL: client.user?.displayAvatarURL() + }) + .setTimestamp(); + + mainEmbed.addFields({ + name: '๐Ÿ“‚ Music & Audio', + value: 'Streaming, playlists, filters, and audio queues via Lavalink', + inline: true + }, { + name: '๐ŸŽž Reaction GIFs', + value: 'Search and share GIFs using the bot', + inline: true + }, { + name: '๐ŸŽฎ Twitch Live Alerts', + value: 'Notifications when streamers go live', + inline: true + }, { + name: '๐Ÿ”จ Moderation', + value: 'Server management, audit logging, moderation tools', + inline: true + }, { + name: 'โš™๏ธ Utilities & General', + value: 'Reminders, settings, commands, and server utilities', + inline: true + }); + + 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('๐Ÿ ') + ); + + 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 + }); + return; + } + + 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}`) + .setColor(0x4f46e5) + .setThumbnail(client.user?.displayAvatarURL() || null) + .setDescription( + cmds.map(c => `**/${c.name}**: ${c.description}`).join('\n') + ) + .setFooter({ + text: `Category: ${label} โ€ข Use /help [command] for details`, + 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..a63c04469 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'; 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..86e31b0ee 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'; 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..592df6c35 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'; 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..a18269923 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'; 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..7b9db38fa --- /dev/null +++ b/apps/bot/src/commands/other/poll.ts @@ -0,0 +1,398 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +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..90af8b4bb 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'; 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..dd14749ce 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'; 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..91ab7d7b5 --- /dev/null +++ b/apps/bot/src/commands/other/reminder.ts @@ -0,0 +1,359 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { formatReminderText } from '../../lib/reminders/ReminderManager'; +import Logger from '../../lib/logger'; + +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 { + this.container.client.session.reminders.create({ + userId, + guildId: interaction.guildId ?? '', + event, + description, + dateTime: targetDate.toISOString(), + repeat: null, + timeOffset: 0 + }); + } catch (err) { + Logger.error('Failed to save reminder to session: ', 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 session + this.container.client.session.reminders + .delete({ userId, guildId: interaction.guildId ?? '', event }); + } catch (notifyErr) { + Logger.error('Reminder notification delivery error: ', notifyErr); + } + }, durationMs); + + return; + } + + case 'list': { + try { + const { reminders } = + this.container.client.session.reminders.getByUserId({ + userId, + guildId: interaction.guildId ?? '' + }); + + 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 { reminder: del } = + this.container.client.session.reminders.delete({ + userId, + guildId: interaction.guildId ?? '', + event + }); + if (del?.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..e4aadf151 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'; 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..762ebf3c6 --- /dev/null +++ b/apps/bot/src/commands/other/set.ts @@ -0,0 +1,447 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { + ChannelType, + PermissionFlagsBits, + type ChatInputCommandInteraction, + type GuildMember +} from 'discord.js'; +import Logger from '../../lib/logger'; +import { checkTwitchEnabled } from '../../lib/set/twitch'; +import { + handleWelcomeChannel, + handleWelcomeMessage, + handleWelcomeToggle, + handleWelcomeTest +} from '../../lib/set/welcome'; +import { + handleTwitchAdd, + handleTwitchRemove, + handleTwitchList +} from '../../lib/set/twitch'; +import { + handleLogChannel, + handleLogToggle, + handleLogDisable +} from '../../lib/set/logging'; +import { + handleTicketChannel, + handleTicketToggle, + handleTicketPanel, + handleTicketTranscript, + handleTicketTranscriptDisable, + handleTicketRole, + handleTicketRoleDisable +} from '../../lib/set/tickets'; +import { handleDefaultVolume } from '../../lib/set/volume'; +import { handleView } from '../../lib/set/view'; + +const subcommandHandlers: Record Promise> = { + 'welcome-channel': handleWelcomeChannel, + 'welcome-message': handleWelcomeMessage, + 'welcome-toggle': handleWelcomeToggle, + 'welcome-test': handleWelcomeTest, + 'twitch-add': handleTwitchAdd, + 'twitch-remove': handleTwitchRemove, + 'twitch-list': handleTwitchList, + 'log-channel': handleLogChannel, + 'log-toggle': handleLogToggle, + 'log-disable': handleLogDisable, + 'ticket-channel': handleTicketChannel, + 'ticket-toggle': handleTicketToggle, + 'ticket-panel': handleTicketPanel, + 'ticket-transcript': handleTicketTranscript, + 'ticket-transcript-disable': handleTicketTranscriptDisable, + 'ticket-role': handleTicketRole, + 'ticket-role-disable': handleTicketRoleDisable, + 'default-volume': handleDefaultVolume, + view: handleView +}; + +@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 member = interaction.member as GuildMember; + + 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); + const handler = subcommandHandlers[subcommand]; + + try { + if (handler) { + return await handler(interaction); + } + return await interaction.editReply({ + content: ':warning: Unknown `/set` subcommand.' + }); + } 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 + } + ] +}; \ No newline at end of file diff --git a/apps/bot/src/commands/other/speedrun.ts b/apps/bot/src/commands/other/speedrun.ts index 38edac0bd..9db482aee 100644 --- a/apps/bot/src/commands/other/speedrun.ts +++ b/apps/bot/src/commands/other/speedrun.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; import { ApplyOptions } from '@sapphire/decorators'; import { PaginatedMessage } from '@sapphire/discord.js-utilities'; import { Command, CommandOptions } from '@sapphire/framework'; @@ -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..77428f66e --- /dev/null +++ b/apps/bot/src/commands/other/tic-tac-toe.ts @@ -0,0 +1,183 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { TicTacToeGame } from '../../lib/games/tic-tac-toe'; +import { GameInvite } from '../../lib/games/inviteEmbed'; +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..6c88dae7f 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'; 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'; + @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..af850a657 100644 --- a/apps/bot/src/commands/other/trump.ts +++ b/apps/bot/src/commands/other/trump.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -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..7830d84c2 100644 --- a/apps/bot/src/commands/other/tv-show-search.ts +++ b/apps/bot/src/commands/other/tv-show-search.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { PaginatedMessage } from '@sapphire/discord.js-utilities'; @@ -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..7a2926760 100644 --- a/apps/bot/src/commands/other/urban.ts +++ b/apps/bot/src/commands/other/urban.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -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..d18444e9c --- /dev/null +++ b/apps/bot/src/commands/other/weather.ts @@ -0,0 +1,223 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import Logger from '../../lib/logger'; + +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..be6ed493a --- /dev/null +++ b/apps/bot/src/commands/other/world-news.ts @@ -0,0 +1,217 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command, CommandOptions } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { env } from '../../env'; +import Logger from '../../lib/logger'; + +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 = env.NEWS_API || process.env.NEWS_API; + 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..499bb1c48 100644 --- a/apps/bot/src/commands/twitch/twitch-status.ts +++ b/apps/bot/src/commands/twitch/twitch-status.ts @@ -1,3 +1,4 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; import { ApplyOptions } from '@sapphire/decorators'; import { Command, CommandOptions, container } from '@sapphire/framework'; import { EmbedBuilder } from 'discord.js'; @@ -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/env.ts b/apps/bot/src/env.ts index 2985eec75..2f1ef7e1e 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -1,33 +1,35 @@ -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 +const envSchema = z.object({ + DISCORD_TOKEN: z.string(), + KLIPY_API: z.string().optional(), + NEWS_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(), + // Feature Toggles + LAVA_ENABLED: z.string().optional(), + GIFS_ENABLED: z.string().optional(), + TWITCH_ENABLED: z.string().optional(), + NEWS_ENABLED: z.string().optional(), + IGDB_ENABLED: z.string().optional(), + // Lavalink + LAVA_EXTERNAL: z.string().optional(), + LAVA_HOST: z.string().optional(), + LAVA_PORT: z.string().optional(), + LAVA_PASS: z.string().optional(), + LAVA_SECURE: z.string().optional(), + YOUTUBE_API_KEY: z.string().optional(), + YOUTUBE_REFRESH_TOKEN: z.string().optional(), + YOUTUBE_CIPHER_URL: z.string().optional(), + YOUTUBE_CIPHER_PASSWORD: z.string().optional(), + SPOTIFY_CLIENT_ID: z.string().optional(), + SPOTIFY_CLIENT_SECRET: z.string().optional(), + // SoundCloud (optional โ€” built-in Lavalink source is free; keys only needed for lavasrc plugin) + SOUNDCLOUD_CLIENT_ID: z.string().optional(), + SOUNDCLOUD_CLIENT_SECRET: z.string().optional() }); + +export const env = envSchema.parse(process.env); diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 942c905bd..4b2bc0e96 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,110 +1,288 @@ import { ExtendedClient } from './lib/structures/ExtendedClient'; import { env } from './env'; -import { load } from '@lavaclient/spotify'; import { ApplicationCommandRegistries, + Events, RegisterBehavior } from '@sapphire/framework'; -import { ActivityType } from 'discord.js'; -import Logger from './lib/logger'; +import { ReminderManager } from './lib/reminders/ReminderManager'; +import { StatusManager } from './lib/presence/StatusManager'; import { notify } from './lib/twitch/notifyChannels'; -import { trpcNode } from './trpc'; +import Logger from './lib/logger'; 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 client = new ExtendedClient(); -client.on('ready', async () => { - client.music.connect(client.user!.id); - client.user?.setActivity('/', { - type: ActivityType.Watching - }); +const isLavalinkEnabled = + (env.LAVA_ENABLED || process.env.LAVA_ENABLED)?.toLowerCase() === 'true'; - client.user?.setStatus('online'); - const token = client.twitch.auth.access_token; - if (!token) return; +client.on(Events.ClientReady, async () => { + if (!client.user) return; - // 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: {} - }; + if (isLavalinkEnabled) { + 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); } - await notify(query).then(() => - setInterval(async () => { - const newQuery: string[] = []; - // pickup newly added entries - for (const key in client.twitch.notifyList) { - newQuery.push(key); + } else { + Logger.info( + 'Lavalink audio engine is currently disabled while music commands undergo upgrades.' + ); + } + + // Initialize dynamic rotating presence status + StatusManager.start(client); + + // Initialize Reminder Manager scheduler + ReminderManager.start(client); + + // Publish guild data to Redis for dashboard live view + // (the DB is only used for persistence across boots; dashboard reads live from Redis) + if (client.music.queues.redis) { + await Promise.all( + Array.from(client.session.guilds.entries()).map( + async ([guildId, guild]) => { + try { + await client.music.queues.redis.hset( + 'guilds', + guildId, + JSON.stringify({ name: guild.name, id: guild.id, icon: null }) + ); + } catch {} } - await notify(newQuery); - }, 60 * 1000) + ) ); - } catch (err) { - Logger.error('Prisma ' + err); } -}); -client.on('chatInputCommandError', err => { - console.log('Command Chat Input ' + err); + // Twitch notification setup + const isTwitchEnabled = + (env.TWITCH_ENABLED || process.env.TWITCH_ENABLED)?.toLowerCase() !== + 'false'; + + if ( + isTwitchEnabled && + process.env.TWITCH_CLIENT_ID && + process.env.TWITCH_CLIENT_SECRET + ) { + const initTwitch = async () => { + try { + const notifyDB = await client.session.getAllTwitchConfig(); + 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); + } + } }); -client.on('contextMenuCommandError', err => { - console.log('Command Context Menu ' + err); + +// Sapphire Framework Error Events +client.on(Events.ChatInputCommandError, (error, payload) => { + Logger.error( + `Command Chat Input Error [${payload?.command?.name || 'unknown'}]: `, + error + ); }); -client.on('commandAutocompleteInteractionError', err => { - console.log('Command Autocomplete ' + err); + +client.on(Events.ContextMenuCommandError, (error, payload) => { + Logger.error( + `Command Context Menu Error [${payload?.command?.name || 'unknown'}]: `, + error + ); }); -client.on('commandApplicationCommandRegistryError', err => { - console.log('Command Registry ' + err); + +client.on(Events.CommandAutocompleteInteractionError, (error, payload) => { + Logger.error( + `Command Autocomplete Error [${payload?.command?.name || 'unknown'}]: `, + error + ); }); -client.on('messageCommandError', err => { - console.log('Command ' + err); + +client.on(Events.CommandApplicationCommandRegistryError, (error, command) => { + Logger.error( + `Command Registry Error [${command?.name || 'unknown'}]: `, + error + ); }); -client.on('interactionHandlerError', err => { - console.log('Interaction ' + err); + +client.on(Events.MessageCommandError, (error, payload) => { + Logger.error( + `Message Command Error [${payload?.command?.name || 'unknown'}]: `, + error + ); }); -client.on('interactionHandlerParseError', err => { - console.log('Interaction Parse ' + err); + +client.on(Events.InteractionHandlerError, (error, payload) => { + Logger.error( + `Interaction Handler Error [${payload?.handler?.name || 'unknown'}]: `, + error + ); }); -client.on('listenerError', err => { - console.log('Client Listener ' + err); +client.on(Events.InteractionHandlerParseError, (error, payload) => { + Logger.error( + `Interaction Handler Parse Error [${payload?.handler?.name || 'unknown'}]: `, + error + ); }); -// LavaLink -client.music.on('error', err => { - console.log('LavaLink ' + err); +client.on(Events.ListenerError, (error, payload) => { + Logger.error( + `Client Listener Error [${payload?.piece?.name || 'unknown'}]: `, + error + ); }); +// Lavalink Node & Track Event Handlers (Gated behind isLavalinkEnabled) +if (isLavalinkEnabled) { + 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(); + // In Lavalink, 'replaced' occurs when a new track is started explicitly (skip / new play) + // 'cleanup' occurs when player is destroyed + 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); +} + const main = async () => { try { + await client.session.init(); await client.login(env.DISCORD_TOKEN); } catch (error) { - console.log('Bot errored out', error); + Logger.error('Bot failed to login / errored out: ', error); client.destroy(); process.exit(1); } + + // Sync all actual Discord guilds to DB/Redis on ready (fix missing guild rows) + client.once('ready', async () => { + try { + for (const [gid, guild] of client.session.guilds.entries()) { + try { + await client.session.store.ensureGuildRow(guild); + } catch {} + if (client.music.queues?.redis) { + await client.music.queues.redis.hset( + 'guilds', gid, + JSON.stringify({ name: guild.name || 'Unknown', id: gid, icon: null }) + ); + } + } + } catch (e) { + Logger.warn('Guild sync note: ' + (e instanceof Error ? e.message : String(e))); + } + }); }; void main(); + + diff --git a/apps/bot/src/lib/games/connect-4.ts b/apps/bot/src/lib/games/connect-4.ts index fc584e3e0..ed2e69a7c 100644 --- a/apps/bot/src/lib/games/connect-4.ts +++ b/apps/bot/src/lib/games/connect-4.ts @@ -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..144882010 100644 --- a/apps/bot/src/lib/games/tic-tac-toe.ts +++ b/apps/bot/src/lib/games/tic-tac-toe.ts @@ -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..7e1f91f4e --- /dev/null +++ b/apps/bot/src/lib/gifs/searchGif.ts @@ -0,0 +1,116 @@ +import { env } from '../../env'; + +const FALLBACK_GIFS: Record = { + anime: [ + 'https://media.giphy.com/media/6kakh9bc9gImPt2PeM/giphy.gif', + 'https://media.giphy.com/media/qetTtxaGe11daXlVxu/giphy.gif', + 'https://media.giphy.com/media/fpvLiBtx593G4OghfL/giphy.gif' + ], + hug: [ + 'https://media.giphy.com/media/CxBUkGkh91rfiN4Is9/giphy.gif', + 'https://media.giphy.com/media/7KmCCmbmv850stIY8Q/giphy.gif', + 'https://media.giphy.com/media/atAXRsbDK786cs9lqG/giphy.gif' + ], + slap: [ + 'https://media.giphy.com/media/cFkjszYqxaUr4sjZe7/giphy.gif', + 'https://media.giphy.com/media/vVGcjAu5LgbDm9IWfD/giphy.gif', + 'https://media.giphy.com/media/bdrreSrSNK9EtLc9q2/giphy.gif' + ], + pat: [ + 'https://media.giphy.com/media/ozdUXyzG6X1IHboV0I/giphy.gif', + 'https://media.giphy.com/media/51a3tE91baVGh7U6o5/giphy.gif', + 'https://media.giphy.com/media/jLMOq79F9XIrS4ozsa/giphy.gif' + ], + cat: [ + 'https://media.giphy.com/media/bEI6Dsej0pVeasnPxi/giphy.gif', + 'https://media.giphy.com/media/6hKL8BI8rRNrMRFtAx/giphy.gif', + 'https://media.giphy.com/media/vFKqnCdLPNOKc/giphy.gif' + ], + doggo: [ + 'https://media.giphy.com/media/1keIlrrife8A5luADE/giphy.gif', + 'https://media.giphy.com/media/6eLbMsIfUUpTQMLM0A/giphy.gif', + 'https://media.giphy.com/media/6Ml2jjZbq6zXytByAW/giphy.gif' + ], + baka: [ + 'https://media.giphy.com/media/449KlGQiNgUJcLN8Gg/giphy.gif', + 'https://media.giphy.com/media/0k9oZgI9OZyvE32CS8/giphy.gif', + 'https://media.giphy.com/media/fL17USlobBBQbvoYTn/giphy.gif' + ], + gintama: [ + 'https://media.giphy.com/media/VO7QEhanuAlEu3LhL0/giphy.gif', + 'https://media.giphy.com/media/iw223RP3FSk62M79qt/giphy.gif', + 'https://media.giphy.com/media/DyUejnK0SkLp4vsuyD/giphy.gif' + ], + jojo: [ + 'https://media.giphy.com/media/SICRE9mOzgBOATPUtS/giphy.gif', + 'https://media.giphy.com/media/fXG7DfHYVsrGm5E9zL/giphy.gif', + 'https://media.giphy.com/media/c1PecNgUkkE2X8UwVL/giphy.gif' + ], + waifu: [ + 'https://media.giphy.com/media/OrHEJYbSzwz8QfRUl5/giphy.gif', + 'https://media.giphy.com/media/5JcxsXWUV6Q4k9iDhd/giphy.gif', + 'https://media.giphy.com/media/MmUGJI3JQ1rWIsDtkV/giphy.gif' + ], + amongus: [ + 'https://media.giphy.com/media/0tyOasM1BTUtdyf4nt/giphy.gif', + 'https://media.giphy.com/media/4xe7fdnUbZHMTNPTXc/giphy.gif', + 'https://media.giphy.com/media/kkgGWkhttFE3LgW0Ni/giphy.gif' + ], + gif: [ + 'https://media.giphy.com/media/l0He4tYoErhi0kCDe/giphy.gif', + 'https://media.giphy.com/media/H2fORSKZw4SCQ/giphy.gif', + 'https://media.giphy.com/media/bEI6Dsej0pVeasnPxi/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 = env.KLIPY_API || process.env.KLIPY_API; + 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..3cc4e7270 100644 --- a/apps/bot/src/lib/music/buttonHandler.ts +++ b/apps/bot/src/lib/music/buttonHandler.ts @@ -9,48 +9,103 @@ import { ButtonStyle } from 'discord.js'; import buttonsCollector, { deletePlayerEmbed } from './buttonsCollector'; +import { NowPlayingEmbed } from './nowPlayingEmbed'; +import Logger from '../logger'; -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..a48d500a7 100644 --- a/apps/bot/src/lib/music/buttonsCollector.ts +++ b/apps/bot/src/lib/music/buttonsCollector.ts @@ -5,6 +5,7 @@ import type { Queue } from './classes/Queue'; import { NowPlayingEmbed } from './nowPlayingEmbed'; import type { Song } from './classes/Song'; import Logger from '../logger'; +import { getPlayerActionRows, stopProgressUpdater } from './buttonHandler'; 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..fed324f2b 100644 --- a/apps/bot/src/lib/music/channelHandler.ts +++ b/apps/bot/src/lib/music/channelHandler.ts @@ -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..4f6a29a1c 100644 --- a/apps/bot/src/lib/music/classes/Queue.ts +++ b/apps/bot/src/lib/music/classes/Queue.ts @@ -7,14 +7,12 @@ import type { 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 { 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'; export enum LoopType { @@ -38,13 +36,13 @@ 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 { song: Song; @@ -65,7 +63,7 @@ interface QueueKeys { export class Queue { public readonly keys: QueueKeys; - private skipped: boolean; + public skipped: boolean; public constructor( public readonly store: QueueStore, @@ -91,15 +89,23 @@ export class Queue { } 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 { + const current = await this.getCurrentTrack(); + return Boolean(current); } public get paused(): boolean { - return this.player.paused; + return Boolean(this.player?.paused); } public get guild(): Guild { @@ -109,32 +115,33 @@ 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(); } } @@ -143,12 +150,36 @@ export class Queue { 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( @@ -183,7 +214,7 @@ export class Queue { } public async pause(interaction?: CommandInteraction) { - await this.player.pause(true); + await this.player.pause(); await this.setSystemPaused(false); if (interaction) { this.client.emit('musicSongPause', interaction); @@ -191,7 +222,7 @@ export class Queue { } public async resume(interaction?: CommandInteraction) { - await this.player.pause(false); + await this.player.resume(); await this.setSystemPaused(false); if (interaction) { this.client.emit('musicSongResume', interaction); @@ -232,16 +263,16 @@ export class Queue { let data = await this.store.redis.get(this.keys.volume); if (!data) { - const guildQuery = await trpcNode.guild.getGuild.query({ - id: this.guildID - }); + const guildData = this.client.session.guildData + .getGuild({ id: this.guildID }) + .guild; - if (!guildQuery || !guildQuery.guild) + if (!guildData || !guildData.volume) await this.setVolume(this.player.volume ?? 100); // saves to both - if (guildQuery.guild) + if (guildData && guildData.volume) data = - guildQuery.guild.volume.toString() || this.player.volume.toString(); + guildData.volume.toString() || this.player.volume.toString(); } return data ? Number(data) : 100; @@ -255,9 +286,9 @@ export class Queue { const previous = await this.store.redis.getset(this.keys.volume, value); await this.refresh(); - await trpcNode.guild.updateVolume.mutate({ + this.client.session.guildData.updateVolume({ guildId: this.guildID, - volume: this.player.volume + volume: value }); this.client.emit('musicSongVolumeUpdate', this, value); @@ -273,7 +304,10 @@ export class Queue { // 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 @@ -282,12 +316,13 @@ export class Queue { 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(); } @@ -388,7 +423,7 @@ export class Queue { } public async stop(): Promise { - await this.player.stop(); + await this.destroyPlayer(); } public async clearTracks(): Promise { @@ -396,7 +431,7 @@ export class Queue { } public async skipTo(position: number): Promise { - await this.store.redis.ltrim(this.keys.next, 0, position - 1); + await this.store.redis.ltrim(this.keys.next, 0, -position); await this.next({ skipped: true }); } diff --git a/apps/bot/src/lib/music/classes/QueueClient.ts b/apps/bot/src/lib/music/classes/QueueClient.ts index 55191d846..4481ae008 100644 --- a/apps/bot/src/lib/music/classes/QueueClient.ts +++ b/apps/bot/src/lib/music/classes/QueueClient.ts @@ -1,30 +1,54 @@ import Redis from 'ioredis'; import type { RedisOptions } from 'ioredis'; -import { ConnectionInfo, Node, SendGatewayPayload } from 'lavaclient'; +import { LavalinkManager, LavalinkNodeOptions } from 'lavalink-client'; import { QueueStore } from './QueueStore'; +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 }); + 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, options.redis instanceof Redis ? options.redis : new Redis(options.redis) ); + + 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..5c1da99bb 100644 --- a/apps/bot/src/lib/music/classes/QueueStore.ts +++ b/apps/bot/src/lib/music/classes/QueueStore.ts @@ -1,5 +1,5 @@ import { Collection } from 'discord.js'; -import { readFileSync } from 'fs'; +import { existsSync, readFileSync } from 'fs'; import type { Redis, RedisKey } from 'ioredis'; import { join, resolve } from 'path'; import { Queue } from './Queue'; @@ -38,6 +38,29 @@ export interface ExtendedRedis extends Redis { rpopset: (source: RedisKey, destination: RedisKey) => Promise; } +function getLuaScript(name: string): string { + const candidates = [ + resolve(join(__dirname, '..', '..', '..'), 'audio', `${name}.lua`), + resolve( + join(__dirname, '..', '..', '..'), + 'scripts', + 'audio', + `${name}.lua` + ), + resolve(process.cwd(), 'scripts', 'audio', `${name}.lua`), + resolve(process.cwd(), 'dist', 'audio', `${name}.lua`), + resolve(process.cwd(), 'apps', 'bot', 'scripts', 'audio', `${name}.lua`) + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return readFileSync(candidate, 'utf-8'); + } + } + Logger.error(`Could not find Lua script ${name}.lua`); + return ''; +} + export class QueueStore extends Collection { public redis: ExtendedRedis; @@ -53,16 +76,13 @@ export class QueueStore extends Collection { }); for (const command of commands) { - this.redis.defineCommand(command.name, { - numberOfKeys: command.keys, - lua: readFileSync( - resolve( - join(__dirname, '..', '..', '..'), - 'audio', - `${command.name}.lua` - ) - ).toString() - }); + const luaCode = getLuaScript(command.name); + if (luaCode) { + this.redis.defineCommand(command.name, { + numberOfKeys: command.keys, + lua: luaCode + }); + } } } @@ -85,9 +105,6 @@ export class QueueStore extends Collection { 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', @@ -96,7 +113,6 @@ export class QueueStore extends Collection { [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); } 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..c14d21036 --- /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'; +import { TRIVIA_SONGS, type TriviaSong } from '../triviaSongs'; +import Logger from '../../logger'; +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..9f5e21959 100644 --- a/apps/bot/src/lib/music/nowPlayingEmbed.ts +++ b/apps/bot/src/lib/music/nowPlayingEmbed.ts @@ -1,6 +1,4 @@ -import { container } from '@sapphire/framework'; import { ColorResolvable, EmbedBuilder } from 'discord.js'; -import progressbar from 'string-progressbar'; import type { Song } from './classes/Song'; 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..4960da612 100644 --- a/apps/bot/src/lib/music/searchSong.ts +++ b/apps/bot/src/lib/music/searchSong.ts @@ -1,109 +1,144 @@ import { container } from '@sapphire/framework'; -import { SpotifyItemType } from '@lavaclient/spotify'; import { Song } from './classes/Song'; import type { User } from 'discord.js'; +import { env } from '../../env'; + +/** + * Helper check functions for configured API keys / tokens. + */ +function hasSpotifyKeys(): boolean { + return !!(env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET); +} + +function hasYouTubeKeys(): boolean { + return !!(env.YOUTUBE_API_KEY || env.YOUTUBE_REFRESH_TOKEN); +} + +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..aa325bdd8 --- /dev/null +++ b/apps/bot/src/lib/music/youtubeOAuth.ts @@ -0,0 +1,192 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import type { Client, User } from 'discord.js'; +import Logger from '../logger'; + +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..c8d253dd9 --- /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'; + +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..98b9e085d --- /dev/null +++ b/apps/bot/src/lib/reminders/ReminderManager.ts @@ -0,0 +1,203 @@ +import { EmbedBuilder, type User } from 'discord.js'; +import type { ExtendedClient } from '../structures/ExtendedClient'; +import Logger from '../logger'; + +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: ExtendedClient | null = null; + private static interval: NodeJS.Timeout | null = null; + private static isProcessing = false; + + public static start(client: ExtendedClient): 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 { reminders: dueReminders = [] } = + this.client.session.reminders.getDueReminders({ + beforeIsoDate: nowIso + }); + + 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 + this.client.session.reminders + .delete({ + userId: reminder.userId, + guildId: reminder.guildId, + event: reminder.event + }); + } 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/session/SessionManager.ts b/apps/bot/src/lib/session/SessionManager.ts new file mode 100644 index 000000000..564116eca --- /dev/null +++ b/apps/bot/src/lib/session/SessionManager.ts @@ -0,0 +1,161 @@ +import { PrismaClient } from '@prisma/client'; +import type { GuildRecord } from './types'; +import { SessionStore } from './SessionStore'; +import { createUsersHandlers } from './handlers/users'; +import { createGuildDataHandlers } from './handlers/guildData'; +import { createWelcomeMessagesHandlers } from './handlers/welcomeMessages'; +import { createTicketsHandlers } from './handlers/tickets'; +import { createTwitchConfigHandlers } from './handlers/twitchConfig'; +import { createHubChannelsHandlers } from './handlers/hubChannels'; +import { createPlaylistsHandlers } from './handlers/playlists'; +import { createSongsHandlers } from './handlers/songs'; +import { createRemindersHandlers } from './handlers/reminders'; +import { createCommandsHandlers } from './handlers/commands'; +import { createMembersHandlers } from './handlers/members'; + +export type { + UserRecord, + SongRecord, + Playlist, + Reminder, + MemberRecord, + Ticket, + TempChannel, + TwitchNotification, + GuildRecord +} from './types'; + +/** + * Facade over the session namespaces. Each namespace is built by a dedicated + * handler factory operating on a shared `SessionStore` (see `handlers/`). + */ +export class SessionManager { + public readonly store: SessionStore; + + public readonly users: ReturnType; + public readonly guildData: ReturnType; + public readonly welcomeMessages: ReturnType; + public readonly tickets: ReturnType; + public readonly twitchConfig: ReturnType; + public readonly hubChannels: ReturnType; + public readonly playlists: ReturnType; + public readonly songs: ReturnType; + public readonly reminders: ReturnType; + public readonly commands: ReturnType; + public readonly members: ReturnType; + + public constructor(db?: PrismaClient) { + this.store = new SessionStore(db); + this.users = createUsersHandlers(this.store); + const guildData = createGuildDataHandlers(this.store); + this.guildData = guildData; + this.welcomeMessages = createWelcomeMessagesHandlers(this.store); + this.tickets = createTicketsHandlers(this.store); + this.twitchConfig = createTwitchConfigHandlers(this.store, guildData); + this.hubChannels = createHubChannelsHandlers(this.store); + this.playlists = createPlaylistsHandlers(this.store); + this.songs = createSongsHandlers(this.store); + this.reminders = createRemindersHandlers(this.store); + this.commands = createCommandsHandlers(this.store); + this.members = createMembersHandlers(this.store); + } + + /** + * Hydrates all in-memory stores from the SQLite database so persisted + * per-guild settings survive bot restarts. + */ + public async init(): Promise { + await this.store.init(); + } + + public get guilds(): Map { + return this.store.guilds; + } + + public getAllTwitchConfig(): { + notifications: Array<{ + twitchId: string; + channelIds: string[]; + logo?: string; + live: boolean; + sent: boolean; + }>; + } { + return { + notifications: Array.from(this.store.twitchNotifications.values()).map( + notification => ({ + twitchId: notification.userId, + channelIds: notification.channelIds, + logo: notification.logo, + live: notification.live, + sent: notification.sent + }) + ) + }; + } + + /** + * Drops a member's guild-scoped records (tickets, temp channels, + * playlists, reminders, notify list membership) when they leave the guild. + */ + public clearUserGuildData(guildId: string, userId: string): void { + this.members.delete({ guildId, userId }); + + for (const [id, ticket] of this.store.ticketsMap) { + if (ticket.guildId === guildId && ticket.creatorId === userId) { + this.store.ticketsMap.delete(id); + this.store.persist(() => + this.store.db.ticket.delete({ where: { threadId: id } }) + ); + } + } + + for (const [id, channel] of this.store.tempChannels) { + if (channel.guildId === guildId && channel.ownerId === userId) { + this.store.tempChannels.delete(id); + this.store.persist(async () => { + try { + await this.store.db.tempChannel.delete({ where: { id } }); + } catch { + // best-effort + } + }); + } + } + + const playlistsKey = this.store.playerKey(guildId, userId); + if (this.store.playlistsMap.delete(playlistsKey)) { + this.store.persist(async () => { + const dbId = await this.store.getUserDbId(userId); + await this.store.db.playlist.deleteMany({ + where: { guildId, userId: dbId } + }); + }); + } + + const remindersToDelete = Array.from(this.store.remindersMap.values()).filter( + r => r.guildId === guildId && r.userId === userId + ); + for (const reminder of remindersToDelete) { + this.store.remindersMap.delete( + this.store.buildReminderKey(guildId, userId, reminder.event) + ); + } + if (remindersToDelete.length > 0) { + this.store.persist(() => + this.store.db.reminder.deleteMany({ where: { guildId, userId } }) + ); + } + + const guild = this.store.guilds.get(guildId); + if (guild) { + const updated = guild.notifyList.filter(id => id !== userId); + if (updated.length !== guild.notifyList.length) { + guild.notifyList = updated; + this.store.persist(async () => { + await this.store.ensureGuildRow(guild); + }); + } + } + } +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/SessionStore.ts b/apps/bot/src/lib/session/SessionStore.ts new file mode 100644 index 000000000..b65691443 --- /dev/null +++ b/apps/bot/src/lib/session/SessionStore.ts @@ -0,0 +1,368 @@ +import { PrismaClient } from '@prisma/client'; +import type { + GuildRecord, + MemberRecord, + Playlist, + Reminder, + SongRecord, + TempChannel, + Ticket, + TwitchNotification, + UserRecord +} from './types'; +import { + DEFAULT_TICKET_MESSAGE, + DEFAULT_WELCOME_MESSAGE +} from './types'; + +/** + * Owns all in-memory session state plus the persistence layer. Handlers in + * `handlers/` are thin domain objects that read/write through this store. + */ +export class SessionStore { + public readonly db: PrismaClient; + + public usersMap: Map = new Map(); + public guilds: Map = new Map(); + public ticketsMap: Map = new Map(); + public tempChannels: Map = new Map(); + public twitchNotifications: Map = new Map(); + public playlistsMap: Map> = new Map(); + public remindersMap: Map = new Map(); + public membersMap: Map = new Map(); + + public nextPlaylistId = 1; + public nextSongId = 1; + public nextReminderId = 1; + + public constructor(db?: PrismaClient) { + this.db = db ?? new PrismaClient(); + } + + /** + * Hydrates all in-memory stores from the SQLite database so persisted + * per-guild settings survive bot restarts. + */ + public async init(): Promise { + const dbUsers = await this.db.user.findMany(); + for (const u of dbUsers) { + if (!u.discordId) continue; + this.usersMap.set(u.discordId, { + id: u.discordId, + dbId: u.id, + name: u.name ?? 'Unknown', + createdAt: new Date() + }); + } + + const dbGuilds = await this.db.guild.findMany(); + for (const g of dbGuilds) { + this.guilds.set(g.id, { + id: g.id, + name: g.name, + ownerId: g.ownerId, + volume: g.volume, + notifyList: this.parseArray(g.notifyList), + logEvents: g.logEvents || '', + disabledCommands: this.parseArray(g.disabledCommands), + logChannel: g.logChannel ?? undefined, + logChannelEnabled: g.logChannelEnabled, + welcomeMessage: g.welcomeMessage ?? DEFAULT_WELCOME_MESSAGE, + welcomeMessageChannel: g.welcomeMessageChannel ?? undefined, + welcomeMessageEnabled: g.welcomeMessageEnabled, + ticketChannel: g.ticketChannel ?? undefined, + ticketTranscriptChannel: g.ticketTranscriptChannel ?? undefined, + ticketRoleId: g.ticketRoleId ?? undefined, + ticketEnabled: g.ticketEnabled, + ticketMessage: g.ticketMessage ?? DEFAULT_TICKET_MESSAGE, + hub: g.hub ?? undefined, + hubChannel: g.hubChannel ?? undefined + }); + } + + const dbTickets = await this.db.ticket.findMany(); + for (const t of dbTickets) { + this.ticketsMap.set(t.threadId, { + threadId: t.threadId, + guildId: t.guildId, + creatorId: t.creatorId, + createdAt: t.createdAt, + closed: t.closed + }); + } + + const dbTempChannels = await this.db.tempChannel.findMany(); + for (const tc of dbTempChannels) { + this.tempChannels.set(tc.id, { + guildId: tc.guildId, + ownerId: tc.ownerId, + id: tc.id + }); + } + + const dbMembers = await this.db.guildMember.findMany(); + for (const m of dbMembers) { + this.membersMap.set(this.memberKey(m.guildId, m.userId), { + guildId: m.guildId, + userId: m.userId, + joinedAt: m.joinedAt + }); + } + + const dbTwitch = await this.db.twitchNotify.findMany(); + for (const tn of dbTwitch) { + this.twitchNotifications.set(tn.twitchId, { + userId: tn.twitchId, + logo: tn.logo || undefined, + channelIds: this.parseArray(tn.channelIds), + live: tn.live, + sent: tn.sent + }); + } + + const dbPlaylists = await this.db.playlist.findMany({ + include: { songs: true } + }); + const dbIdToDiscord = new Map( + dbUsers.map(u => [u.id, u.discordId] as const) + ); + for (const p of dbPlaylists) { + const discordId = p.userId + ? (dbIdToDiscord.get(p.userId) ?? p.userId) + : 'unknown'; + const userPlaylists = this.getUserPlaylists(p.guildId, discordId); + userPlaylists.set(p.name, { + id: p.id, + name: p.name, + userId: discordId, + guildId: p.guildId, + songs: p.songs.map(s => ({ ...s })) + }); + if (p.id >= this.nextPlaylistId) this.nextPlaylistId = p.id + 1; + for (const s of p.songs) { + if (s.id >= this.nextSongId) this.nextSongId = s.id + 1; + } + } + + const dbReminders = await this.db.reminder.findMany(); + for (const r of dbReminders) { + this.remindersMap.set( + this.buildReminderKey(r.guildId, r.userId, r.event), + { + id: r.id, + createdAt: r.createdAt, + repeat: r.repeat ?? null, + event: r.event, + description: r.description ?? '', + dateTime: r.dateTime, + userId: r.userId, + guildId: r.guildId, + timeOffset: r.timeOffset + } + ); + if (r.id >= this.nextReminderId) this.nextReminderId = r.id + 1; + } + } + + /** + * Queues a database write. The in-memory session always returns + * immediately; persistence is durable but fire-and-forget. Operations run + * serially in call order so foreign keys (e.g. user -> guild) are satisfied. + */ + private persistQueue: Promise = Promise.resolve(); + + public persist(operation: () => Promise): void { + this.persistQueue = this.persistQueue + .then(() => operation()) + .catch(error => { + console.error( + '[SessionManager] DB persist failed: ', + error instanceof Error ? error.message : error + ); + }); + } + + public parseArray(raw: string): string[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + return raw.split(',').map(s => s.trim()).filter(Boolean); + } + } + + public toJson(value: string[]): string { + return JSON.stringify(value); + } + + public async ensureGuildRow(guild: GuildRecord): Promise { + if (guild.ownerId) { + await this.getUserDbId(guild.ownerId); + } + await this.db.guild.upsert({ + where: { id: guild.id }, + create: { + id: guild.id, + name: guild.name, + ownerId: guild.ownerId, + volume: guild.volume, + notifyList: this.toJson(guild.notifyList), + logEvents: guild.logEvents, + disabledCommands: this.toJson(guild.disabledCommands), + logChannel: guild.logChannel ?? null, + logChannelEnabled: guild.logChannelEnabled, + welcomeMessage: guild.welcomeMessage, + welcomeMessageChannel: guild.welcomeMessageChannel ?? null, + welcomeMessageEnabled: guild.welcomeMessageEnabled, + ticketChannel: guild.ticketChannel ?? null, + ticketTranscriptChannel: guild.ticketTranscriptChannel ?? null, + ticketRoleId: guild.ticketRoleId ?? null, + ticketEnabled: guild.ticketEnabled, + ticketMessage: guild.ticketMessage, + hub: guild.hub ?? null, + hubChannel: guild.hubChannel ?? null + }, + update: { + name: guild.name, + ownerId: guild.ownerId, + volume: guild.volume, + notifyList: this.toJson(guild.notifyList), + logEvents: guild.logEvents, + disabledCommands: this.toJson(guild.disabledCommands), + logChannel: guild.logChannel ?? null, + logChannelEnabled: guild.logChannelEnabled, + welcomeMessage: guild.welcomeMessage, + welcomeMessageChannel: guild.welcomeMessageChannel ?? null, + welcomeMessageEnabled: guild.welcomeMessageEnabled, + ticketChannel: guild.ticketChannel ?? null, + ticketTranscriptChannel: guild.ticketTranscriptChannel ?? null, + ticketRoleId: guild.ticketRoleId ?? null, + ticketEnabled: guild.ticketEnabled, + ticketMessage: guild.ticketMessage, + hub: guild.hub ?? null, + hubChannel: guild.hubChannel ?? null + } + }); + } + + public async getUserDbId(discordId: string): Promise { + const cached = this.usersMap.get(discordId)?.dbId; + if (cached) return cached; + try { + const user = await this.db.user.findUnique({ + where: { discordId } + }); + if (user) return user.id; + const created = await this.db.user.create({ + data: { discordId, name: 'Unknown' } + }); + return created.id; + } catch { + return null; + } + } + + public getOrCreateGuild(guildId: string): GuildRecord { + let guild = this.guilds.get(guildId); + if (!guild) { + guild = { + id: guildId, + name: guildId, + ownerId: '', + volume: 100, + notifyList: [], + logEvents: '', + welcomeMessage: DEFAULT_WELCOME_MESSAGE, + welcomeMessageEnabled: false, + logChannelEnabled: false, + ticketEnabled: false, + ticketMessage: DEFAULT_TICKET_MESSAGE, + disabledCommands: [] + }; + this.guilds.set(guildId, guild); + } + return guild; + } + + public playerKey(guildId: string, userId: string): string { + return `${guildId}:${userId}`; + } + + public memberKey(guildId: string, userId: string): string { + return `${guildId}:${userId}`; + } + + public getUserPlaylists(guildId: string, userId: string): Map { + const key = this.playerKey(guildId, userId); + let userPlaylists = this.playlistsMap.get(key); + if (!userPlaylists) { + userPlaylists = new Map(); + this.playlistsMap.set(key, userPlaylists); + } + return userPlaylists; + } + + public buildReminderKey(guildId: string, userId: string, event: string): string { + return `${guildId}:${userId}:${event}`; + } + + public addSongToPlaylist(song: SongRecord): void { + for (const userPlaylists of this.playlistsMap.values()) { + for (const playlist of userPlaylists.values()) { + if (playlist.id === song.playlistId) { + playlist.songs.push(song); + return; + } + } + } + } + + public removeSongById(id: number): SongRecord | null { + for (const userPlaylists of this.playlistsMap.values()) { + for (const playlist of userPlaylists.values()) { + const index = playlist.songs.findIndex(s => s.id === id); + if (index !== -1) { + const [song] = playlist.songs.splice(index, 1); + return song; + } + } + } + return null; + } + + public async ensureTwitchRow( + notification: TwitchNotification + ): Promise { + await this.db.twitchNotify.upsert({ + where: { twitchId: notification.userId }, + create: { + twitchId: notification.userId, + logo: notification.logo ?? '', + live: notification.live, + channelIds: this.toJson(notification.channelIds), + sent: notification.sent + }, + update: { + logo: notification.logo ?? '', + live: notification.live, + channelIds: this.toJson(notification.channelIds), + sent: notification.sent + } + }); + } + + public getOrCreateTwitchNotification(userId: string): TwitchNotification { + let notification = this.twitchNotifications.get(userId); + if (!notification) { + notification = { + userId, + channelIds: [], + live: false, + sent: false + }; + this.twitchNotifications.set(userId, notification); + } + return notification; + } +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/commands.ts b/apps/bot/src/lib/session/handlers/commands.ts new file mode 100644 index 000000000..1b0a8ed40 --- /dev/null +++ b/apps/bot/src/lib/session/handlers/commands.ts @@ -0,0 +1,12 @@ +import type { SessionStore } from '../SessionStore'; + +export function createCommandsHandlers(store: SessionStore) { + return { + getDisabledCommands: (input: { + guildId: string; + }): { disabledCommands: string[] } => { + const guild = store.guilds.get(input.guildId); + return { disabledCommands: guild?.disabledCommands || [] }; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/guildData.ts b/apps/bot/src/lib/session/handlers/guildData.ts new file mode 100644 index 000000000..ec16b13eb --- /dev/null +++ b/apps/bot/src/lib/session/handlers/guildData.ts @@ -0,0 +1,71 @@ +import type { GuildRecord } from '../types'; +import type { SessionStore } from '../SessionStore'; + +export function createGuildDataHandlers(store: SessionStore) { + return { + create: (input: { + id: string; + name: string; + ownerId: string; + }): GuildRecord => { + const guild = store.getOrCreateGuild(input.id); + guild.name = input.name; + guild.ownerId = input.ownerId; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + }, + getGuild: (input: { + id: string; + }): { guild: GuildRecord | null } => ({ + guild: store.guilds.get(input.id) || null + }), + delete: (input: { id: string }): boolean => { + const deleted = store.guilds.delete(input.id); + if (deleted) { + store.persist(() => store.db.guild.delete({ where: { id: input.id } })); + } + return deleted; + }, + updateVolume: (input: { + guildId: string; + volume: number; + }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + guild.volume = input.volume; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + }, + setLogChannel: (input: { + guildId: string; + channelId: string | null; + }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + if (input.channelId === null) { + guild.logChannel = undefined; + guild.logChannelEnabled = false; + } else { + guild.logChannel = input.channelId; + guild.logChannelEnabled = true; + } + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + }, + toggleLogChannel: (input: { + guildId: string; + status: boolean; + }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + guild.logChannelEnabled = input.status; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/hubChannels.ts b/apps/bot/src/lib/session/handlers/hubChannels.ts new file mode 100644 index 000000000..8cef6c96d --- /dev/null +++ b/apps/bot/src/lib/session/handlers/hubChannels.ts @@ -0,0 +1,74 @@ +import type { TempChannel } from '../types'; +import type { SessionStore } from '../SessionStore'; + +export function createHubChannelsHandlers(store: SessionStore) { + return { + getTempChannel: (input: { + guildId: string; + ownerId: string; + }): { tempChannel: TempChannel | null } => { + for (const channel of store.tempChannels.values()) { + if ( + channel.guildId === input.guildId && + channel.ownerId === input.ownerId + ) { + return { tempChannel: channel }; + } + } + return { tempChannel: null }; + }, + createTempChannel: (input: { + guildId: string; + ownerId: string; + channelId: string; + }): TempChannel => { + const existing = store.tempChannels.get(input.channelId); + if (existing?.id === input.channelId) return existing; + + for (const [id, channel] of store.tempChannels) { + if (channel.ownerId === input.ownerId) { + store.tempChannels.delete(id); + store.persist(async () => { + try { + await store.db.tempChannel.delete({ where: { id } }); + } catch { + // row may not exist yet; deletes are best-effort + } + }); + } + } + + const channel: TempChannel = { + guildId: input.guildId, + ownerId: input.ownerId, + id: input.channelId + }; + store.tempChannels.set(input.channelId, channel); + store.persist(() => + store.db.tempChannel.create({ + data: { + id: input.channelId, + guildId: input.guildId, + ownerId: input.ownerId + } + }) + ); + return channel; + }, + deleteTempChannel: (input: { channelId: string }): boolean => { + const deleted = store.tempChannels.delete(input.channelId); + if (deleted) { + store.persist(async () => { + try { + await store.db.tempChannel.delete({ + where: { id: input.channelId } + }); + } catch { + // row may not exist yet; deletes are best-effort + } + }); + } + return deleted; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/members.ts b/apps/bot/src/lib/session/handlers/members.ts new file mode 100644 index 000000000..1b989aed3 --- /dev/null +++ b/apps/bot/src/lib/session/handlers/members.ts @@ -0,0 +1,59 @@ +import type { MemberRecord } from '../types'; +import type { SessionStore } from '../SessionStore'; + +export function createMembersHandlers(store: SessionStore) { + return { + create: (input: { guildId: string; userId: string }): MemberRecord => { + const key = store.memberKey(input.guildId, input.userId); + const existing = store.membersMap.get(key); + if (existing) return existing; + const member: MemberRecord = { + guildId: input.guildId, + userId: input.userId, + joinedAt: new Date() + }; + store.membersMap.set(key, member); + store.persist(async () => { + const guild = store.getOrCreateGuild(input.guildId); + await store.ensureGuildRow(guild); + await store.getUserDbId(input.userId); + await store.db.guildMember.upsert({ + where: { + guildId_userId: { + guildId: input.guildId, + userId: input.userId + } + }, + create: { + guildId: input.guildId, + userId: input.userId + }, + update: {} + }); + }); + return member; + }, + delete: (input: { guildId: string; userId: string }): boolean => { + const deleted = store.membersMap.delete( + store.memberKey(input.guildId, input.userId) + ); + if (deleted) { + store.persist(async () => { + try { + await store.db.guildMember.delete({ + where: { + guildId_userId: { + guildId: input.guildId, + userId: input.userId + } + } + }); + } catch { + // row may not exist yet; deletes are best-effort + } + }); + } + return deleted; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/playlists.ts b/apps/bot/src/lib/session/handlers/playlists.ts new file mode 100644 index 000000000..cacc313b8 --- /dev/null +++ b/apps/bot/src/lib/session/handlers/playlists.ts @@ -0,0 +1,81 @@ +import type { Playlist } from '../types'; +import type { SessionStore } from '../SessionStore'; + +export function createPlaylistsHandlers(store: SessionStore) { + return { + create: (input: { + guildId: string; + name: string; + userId: string; + }): Playlist => { + const userPlaylists = store.getUserPlaylists( + input.guildId, + input.userId + ); + if (userPlaylists.has(input.name)) { + throw new Error(`Playlist "${input.name}" already exists`); + } + const playlist: Playlist = { + id: store.nextPlaylistId++, + name: input.name, + userId: input.userId, + guildId: input.guildId, + songs: [] + }; + userPlaylists.set(input.name, playlist); + store.persist(async () => { + const dbId = await store.getUserDbId(input.userId); + await store.db.playlist.create({ + data: { + id: playlist.id, + name: input.name, + guildId: input.guildId, + userId: dbId + } + }); + }); + return playlist; + }, + delete: (input: { + guildId: string; + name: string; + userId: string; + }): Playlist | null => { + const userPlaylists = store.getUserPlaylists( + input.guildId, + input.userId + ); + const playlist = userPlaylists.get(input.name); + if (!playlist) return null; + userPlaylists.delete(input.name); + store.persist(async () => { + const dbId = await store.getUserDbId(input.userId); + await store.db.playlist.deleteMany({ + where: { guildId: input.guildId, userId: dbId, name: input.name } + }); + }); + return playlist; + }, + getPlaylist: (input: { + guildId: string; + name: string; + userId: string; + }): { playlist: Playlist | null } => { + const userPlaylists = store.getUserPlaylists( + input.guildId, + input.userId + ); + return { playlist: userPlaylists.get(input.name) || null }; + }, + getAll: (input: { + guildId: string; + userId: string; + }): { playlists: Playlist[] } => { + return { + playlists: Array.from( + store.getUserPlaylists(input.guildId, input.userId).values() + ) + }; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/reminders.ts b/apps/bot/src/lib/session/handlers/reminders.ts new file mode 100644 index 000000000..e4ae74d0a --- /dev/null +++ b/apps/bot/src/lib/session/handlers/reminders.ts @@ -0,0 +1,93 @@ +import type { Reminder } from '../types'; +import type { SessionStore } from '../SessionStore'; + +export function createRemindersHandlers(store: SessionStore) { + return { + create: (input: { + userId: string; + guildId: string; + event: string; + description: string | null; + dateTime: string; + repeat?: string | null; + timeOffset: number; + }): Reminder => { + const reminder: Reminder = { + id: store.nextReminderId++, + createdAt: new Date(), + repeat: input.repeat ?? null, + event: input.event, + description: input.description || '', + dateTime: input.dateTime, + userId: input.userId, + guildId: input.guildId, + timeOffset: input.timeOffset + }; + store.remindersMap.set( + store.buildReminderKey(input.guildId, input.userId, input.event), + reminder + ); + store.persist(async () => { + const guild = store.getOrCreateGuild(input.guildId); + await store.ensureGuildRow(guild); + await store.db.reminder.create({ + data: { + id: reminder.id, + event: input.event, + description: input.description, + dateTime: input.dateTime, + userId: input.userId, + guildId: input.guildId, + repeat: reminder.repeat, + timeOffset: input.timeOffset + } + }); + }); + return reminder; + }, + getByUserId: (input: { + guildId: string; + userId: string; + }): { reminders: Reminder[] } => ({ + reminders: Array.from(store.remindersMap.values()).filter( + r => r.userId === input.userId && r.guildId === input.guildId + ) + }), + delete: (input: { + userId: string; + guildId: string; + event: string; + }): { reminder: { count: number } } => { + const reminder = store.remindersMap.get( + store.buildReminderKey(input.guildId, input.userId, input.event) + ); + if (reminder) { + store.remindersMap.delete( + store.buildReminderKey(input.guildId, input.userId, input.event) + ); + store.persist(() => + store.db.reminder.deleteMany({ + where: { + userId: input.userId, + guildId: input.guildId, + event: input.event + } + }) + ); + return { reminder: { count: 1 } }; + } + return { reminder: { count: 0 } }; + }, + getDueReminders: (input: { + beforeIsoDate: string; + }): { reminders: Reminder[] } => { + const before = new Date(input.beforeIsoDate).getTime(); + return { + reminders: Array.from(store.remindersMap.values()).filter(r => { + const time = new Date(r.dateTime).getTime(); + return !isNaN(time) && time <= before; + }) + }; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/songs.ts b/apps/bot/src/lib/session/handlers/songs.ts new file mode 100644 index 000000000..96ae60811 --- /dev/null +++ b/apps/bot/src/lib/session/handlers/songs.ts @@ -0,0 +1,33 @@ +import type { SongRecord } from '../types'; +import type { SessionStore } from '../SessionStore'; + +export function createSongsHandlers(store: SessionStore) { + return { + createMany: (input: { + songs: Array>; + }): SongRecord[] => { + const records = input.songs.map(song => { + const record: SongRecord = { + ...song, + id: store.nextSongId++ + }; + store.addSongToPlaylist(record); + return record; + }); + store.persist(() => + store.db.song.createMany({ + data: records + }) + ); + return records; + }, + delete: (input: { id: number }): { song: SongRecord } => { + const song = store.removeSongById(input.id); + if (!song) throw new Error(`Song "${input.id}" not found`); + store.persist(() => + store.db.song.delete({ where: { id: song.id } }) + ); + return { song }; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/tickets.ts b/apps/bot/src/lib/session/handlers/tickets.ts new file mode 100644 index 000000000..81d1a4753 --- /dev/null +++ b/apps/bot/src/lib/session/handlers/tickets.ts @@ -0,0 +1,89 @@ +import type { GuildRecord, Ticket } from '../types'; +import type { SessionStore } from '../SessionStore'; + +export function createTicketsHandlers(store: SessionStore) { + return { + getConfig: (input: { + guildId: string; + }): { guild: GuildRecord | null } => ({ + guild: store.guilds.get(input.guildId) || null + }), + setChannel: (input: { + guildId: string; + channelId: string; + }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + guild.ticketChannel = input.channelId; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + }, + toggle: (input: { guildId: string; status: boolean }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + guild.ticketEnabled = input.status; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + }, + setTranscriptChannel: (input: { + guildId: string; + channelId: string | null; + }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + guild.ticketTranscriptChannel = input.channelId || undefined; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + }, + setRole: (input: { + guildId: string; + roleId: string | null; + }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + guild.ticketRoleId = input.roleId || undefined; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + }, + createTicket: (input: { + guildId: string; + threadId: string; + creatorId: string; + }): Ticket => { + const ticket: Ticket = { + threadId: input.threadId, + guildId: input.guildId, + creatorId: input.creatorId, + createdAt: new Date(), + closed: false + }; + store.ticketsMap.set(input.threadId, ticket); + store.persist(() => + store.db.ticket.create({ + data: { + guildId: input.guildId, + threadId: input.threadId, + creatorId: input.creatorId + } + }) + ); + return ticket; + }, + closeTicket: (input: { threadId: string }): Ticket | null => { + const ticket = store.ticketsMap.get(input.threadId); + if (!ticket) return null; + ticket.closed = true; + store.persist(() => + store.db.ticket.update({ + where: { threadId: input.threadId }, + data: { closed: true, closedAt: new Date() } + }) + ); + return ticket; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/twitchConfig.ts b/apps/bot/src/lib/session/handlers/twitchConfig.ts new file mode 100644 index 000000000..e14d02338 --- /dev/null +++ b/apps/bot/src/lib/session/handlers/twitchConfig.ts @@ -0,0 +1,119 @@ +import type { GuildRecord, TwitchNotification } from '../types'; +import type { SessionStore } from '../SessionStore'; +import type { createGuildDataHandlers } from './guildData'; + +type GuildDataHandlers = ReturnType; + +export function createTwitchConfigHandlers( + store: SessionStore, + guildData: GuildDataHandlers +) { + return { + create: (input: { + userId: string; + userImage: string; + channelId: string; + sendTo: string[]; + }): TwitchNotification => { + let notification = store.twitchNotifications.get(input.userId); + if (!notification) { + notification = { + userId: input.userId, + logo: input.userImage, + channelIds: [], + live: false, + sent: false + }; + store.twitchNotifications.set(input.userId, notification); + } else { + notification.logo = input.userImage; + } + notification.channelIds = Array.from( + new Set([...notification.channelIds, input.channelId]) + ); + store.persist(async () => { + await store.ensureTwitchRow(notification); + }); + return notification; + }, + createViaTwitchNotification: (input: { + name: string; + guildId: string; + notifyList: string[]; + ownerId: string; + userId: string; + }): GuildRecord => { + store.persist(async () => { + await store.getUserDbId(input.ownerId); + await store.ensureTwitchRow({ + userId: input.userId, + channelIds: [], + live: false, + sent: false + }); + await store.ensureGuildRow( + store.getOrCreateGuild(input.guildId) + ); + }); + return guildData.create({ + id: input.guildId, + name: input.name, + ownerId: input.ownerId + }); + }, + updateTwitchNotifications: (input: { + guildId: string; + notifyList: string[]; + }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + guild.notifyList = input.notifyList; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + }, + findUserById: (input: { + id: string; + }): { notification: { channelIds: string[] } | null } => { + const notification = store.twitchNotifications.get(input.id); + return { + notification: notification + ? { channelIds: notification.channelIds } + : null + }; + }, + delete: (input: { userId: string }): boolean => { + const deleted = store.twitchNotifications.delete(input.userId); + if (deleted) { + store.persist(() => + store.db.twitchNotify.delete({ where: { twitchId: input.userId } }) + ); + } + return deleted; + }, + updateNotification: (input: { + userId: string; + channelIds: string[]; + }): TwitchNotification => { + const notification = store.getOrCreateTwitchNotification(input.userId); + notification.channelIds = input.channelIds; + store.persist(async () => { + await store.ensureTwitchRow(notification); + }); + return notification; + }, + updateNotificationStatus: (input: { + userId: string; + sent: boolean; + live: boolean; + }): TwitchNotification => { + const notification = store.getOrCreateTwitchNotification(input.userId); + notification.sent = input.sent; + notification.live = input.live; + store.persist(async () => { + await store.ensureTwitchRow(notification); + }); + return notification; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/users.ts b/apps/bot/src/lib/session/handlers/users.ts new file mode 100644 index 000000000..e95f64d34 --- /dev/null +++ b/apps/bot/src/lib/session/handlers/users.ts @@ -0,0 +1,26 @@ +import type { UserRecord } from '../types'; +import type { SessionStore } from '../SessionStore'; + +export function createUsersHandlers(store: SessionStore) { + return { + create: (input: { id: string; name: string }): UserRecord => { + const existing = store.usersMap.get(input.id); + if (existing) return existing; + const user: UserRecord = { + id: input.id, + name: input.name, + createdAt: new Date() + }; + store.usersMap.set(input.id, user); + store.persist(async () => { + const created = await store.db.user.upsert({ + where: { discordId: input.id }, + create: { discordId: input.id, name: input.name }, + update: { name: input.name } + }); + user.dbId = created.id; + }); + return user; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/handlers/welcomeMessages.ts b/apps/bot/src/lib/session/handlers/welcomeMessages.ts new file mode 100644 index 000000000..8ed6d8b81 --- /dev/null +++ b/apps/bot/src/lib/session/handlers/welcomeMessages.ts @@ -0,0 +1,37 @@ +import type { GuildRecord } from '../types'; +import type { SessionStore } from '../SessionStore'; + +export function createWelcomeMessagesHandlers(store: SessionStore) { + return { + setChannel: (input: { + guildId: string; + channelId: string; + }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + guild.welcomeMessageChannel = input.channelId; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + }, + setMessage: (input: { + guildId: string; + message: string; + }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + guild.welcomeMessage = input.message; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + }, + toggle: (input: { guildId: string; status: boolean }): GuildRecord => { + const guild = store.getOrCreateGuild(input.guildId); + guild.welcomeMessageEnabled = input.status; + store.persist(async () => { + await store.ensureGuildRow(guild); + }); + return guild; + } + }; +} \ No newline at end of file diff --git a/apps/bot/src/lib/session/types.ts b/apps/bot/src/lib/session/types.ts new file mode 100644 index 000000000..434e02c57 --- /dev/null +++ b/apps/bot/src/lib/session/types.ts @@ -0,0 +1,102 @@ +export interface UserRecord { + id: string; + name: string; + createdAt: Date; + dbId?: string; +} + +export interface SongRecord { + 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; + name: string; + userId: string; + guildId: string; + songs: SongRecord[]; +} + +export interface Reminder { + id: number; + createdAt: Date; + repeat?: string | null; + event: string; + description: string; + dateTime: string; + userId: string; + guildId: string; + timeOffset: number; +} + +export interface MemberRecord { + guildId: string; + userId: string; + joinedAt: Date; +} + +export interface Ticket { + threadId: string; + guildId: string; + creatorId: string; + createdAt: Date; + closed: boolean; +} + +export interface TempChannel { + guildId: string; + ownerId: string; + id: string; +} + +export interface TwitchNotification { + userId: string; + logo?: string; + channelIds: string[]; + live: boolean; + sent: boolean; +} + +export interface GuildRecord { + id: string; + name: string; + ownerId: string; + volume: number; + notifyList: string[]; + logEvents: string; + disabledCommands: string[]; + logChannel?: string; + logChannelEnabled: boolean; + welcomeMessage: string; + welcomeMessageChannel?: string; + welcomeMessageEnabled: boolean; + ticketChannel?: string; + ticketTranscriptChannel?: string; + ticketRoleId?: string; + ticketEnabled: boolean; + ticketMessage: string; + hub?: string; + hubChannel?: string; +} + +export const DEFAULT_WELCOME_MESSAGE = + '๐Ÿ‘‹ Welcome {user} to **{server}**! You are member #{memberCount}.'; +export const DEFAULT_TICKET_MESSAGE = + '๐Ÿ‘‹ 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.'; \ No newline at end of file diff --git a/apps/bot/src/lib/set/logging.ts b/apps/bot/src/lib/set/logging.ts new file mode 100644 index 000000000..8fc4ea98f --- /dev/null +++ b/apps/bot/src/lib/set/logging.ts @@ -0,0 +1,37 @@ +import { container } from '@sapphire/framework'; +import type { SetHandler } from './types'; + +export const handleLogChannel: SetHandler = async interaction => { + const channel = interaction.options.getChannel('channel', true); + await container.client.session.guildData.setLogChannel({ + guildId: interaction.guildId!, + channelId: channel.id + }); + return await interaction.editReply({ + content: `:white_check_mark: Server audit & moderation logs enabled and routed to <#${channel.id}>.` + }); +}; + +export const handleLogToggle: SetHandler = async interaction => { + const enabled = interaction.options.getBoolean('enabled', true); + await container.client.session.guildData.toggleLogChannel({ + guildId: interaction.guildId!, + status: enabled + }); + return await interaction.editReply({ + content: `:white_check_mark: Server audit & moderation logging is now **${ + enabled ? 'ENABLED' : 'DISABLED' + }**.` + }); +}; + +export const handleLogDisable: SetHandler = async interaction => { + await container.client.session.guildData.setLogChannel({ + guildId: interaction.guildId!, + channelId: null + }); + return await interaction.editReply({ + content: + ':white_check_mark: Server audit & moderation logging has been **DISABLED**.' + }); +}; \ No newline at end of file diff --git a/apps/bot/src/lib/set/tickets.ts b/apps/bot/src/lib/set/tickets.ts new file mode 100644 index 000000000..2e77d3471 --- /dev/null +++ b/apps/bot/src/lib/set/tickets.ts @@ -0,0 +1,204 @@ +import { container } from '@sapphire/framework'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type ChatInputCommandInteraction, + type TextChannel +} from 'discord.js'; +import type { SetHandler } from './types'; + +const DEFAULT_TICKET_MESSAGE = + '๐Ÿ‘‹ 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.'; + +function getTicketPanel( + interaction: ChatInputCommandInteraction, + ticketMessage: string | null | undefined +) { + const template = + ticketMessage && ticketMessage.trim().length > 0 + ? ticketMessage + : DEFAULT_TICKET_MESSAGE; + + 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 + ); + + return { + embeds: [panelEmbed], + components: [row] + }; +} + +export const handleTicketChannel: SetHandler = async interaction => { + const { client } = container; + const guildId = interaction.guildId!; + const channel = interaction.options.getChannel( + 'channel', + true + ) as TextChannel; + await client.session.tickets.setChannel({ + guildId, + channelId: channel.id + }); + + const ticketConfig = await client.session.tickets.getConfig({ + guildId + }); + const panel = getTicketPanel( + interaction, + ticketConfig.guild?.ticketMessage + ); + + await channel.send(panel).catch(() => {}); + + return await interaction.editReply({ + content: `:white_check_mark: Support ticket channel set to <#${channel.id}> and the interactive ticket panel has been posted!` + }); +}; + +export const handleTicketToggle: SetHandler = async interaction => { + const { client } = container; + const guildId = interaction.guildId!; + const enabled = interaction.options.getBoolean('enabled', true); + await client.session.tickets.toggle({ + guildId, + status: enabled + }); + + if (enabled && interaction.guild) { + const ticketConfig = await client.session.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 panel = getTicketPanel( + interaction, + ticketConfig.guild?.ticketMessage + ); + await targetChannel.send(panel).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.' : '.'}` + }); +}; + +export const handleTicketPanel: SetHandler = async interaction => { + const { client } = container; + const guildId = interaction.guildId!; + const ticketConfig = await client.session.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 panel = getTicketPanel( + interaction, + ticketConfig.guild?.ticketMessage + ); + + await targetChannel.send(panel); + + return await interaction.editReply({ + content: `:white_check_mark: Interactive ticket panel has been posted in <#${channelId}>!` + }); +}; + +export const handleTicketTranscript: SetHandler = async interaction => { + const channel = interaction.options.getChannel('channel', true); + await container.client.session.tickets.setTranscriptChannel({ + guildId: interaction.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.` + }); +}; + +export const handleTicketTranscriptDisable: SetHandler = async interaction => { + await container.client.session.tickets.setTranscriptChannel({ + guildId: interaction.guildId!, + channelId: null + }); + return await interaction.editReply({ + content: + ':white_check_mark: Ticket transcript archival has been **DISABLED**.' + }); +}; + +export const handleTicketRole: SetHandler = async interaction => { + const role = interaction.options.getRole('role', true); + await container.client.session.tickets.setRole({ + guildId: interaction.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.` + }); +}; + +export const handleTicketRoleDisable: SetHandler = async interaction => { + await container.client.session.tickets.setRole({ + guildId: interaction.guildId!, + roleId: null + }); + return await interaction.editReply({ + content: ':white_check_mark: Ticket manager role has been **DISABLED**.' + }); +}; \ No newline at end of file diff --git a/apps/bot/src/lib/set/twitch.ts b/apps/bot/src/lib/set/twitch.ts new file mode 100644 index 000000000..5111d4f6f --- /dev/null +++ b/apps/bot/src/lib/set/twitch.ts @@ -0,0 +1,228 @@ +import { container } from '@sapphire/framework'; +import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; +import { EmbedBuilder } from 'discord.js'; +import { notify } from '../twitch/notifyChannels'; +import { MessageChannel } from '../structures/ExtendedClient'; +import type { SetHandler } from './types'; + +export 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) + ); +} + +export const handleTwitchAdd: SetHandler = async interaction => { + if (!checkTwitchEnabled()) { + return await interaction.editReply({ + content: + ':warning: Twitch features are currently disabled in configuration.' + }); + } + const { client } = container; + const guildId = interaction.guildId!; + 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 client.session.guildData.getGuild({ + id: guildId + }); + if (!guildDB.guild) { + return await interaction.editReply({ + content: ':x: Server data not found.' + }); + } + + if (guildDB.guild.notifyList.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 client.session.twitchConfig.create({ + userId: user.id, + userImage: user.profile_image_url, + channelId: channelData.id, + sendTo: updatedSendTo + }); + + const concatedArray = Array.from( + new Set([...guildDB.guild.notifyList, user.id]) + ); + await client.session.twitchConfig.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}>.` + }); +}; + +export const handleTwitchRemove: SetHandler = async interaction => { + if (!checkTwitchEnabled()) { + return await interaction.editReply({ + content: + ':warning: Twitch features are currently disabled in configuration.' + }); + } + const { client } = container; + const guildId = interaction.guildId!; + 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: Error looking up streamer '${streamerName}'.` + }); + } + + if (!user) + return await interaction.editReply({ + content: `:x: Streamer **${streamerName}** not found.` + }); + + const guildDB = await client.session.guildData.getGuild({ + id: guildId + }); + if (!guildDB.guild || !guildDB.guild.notifyList.includes(user.id)) { + return await interaction.editReply({ + content: `:x: **${user.display_name}** is not in this server's alert list.` + }); + } + + const filteredTwitchIds = guildDB.guild.notifyList.filter( + id => id !== user.id + ); + await client.session.twitchConfig.updateTwitchNotifications({ + guildId, + notifyList: filteredTwitchIds + }); + + const notifyDB = await client.session.twitchConfig.findUserById({ + id: user.id + }); + if (notifyDB?.notification) { + const filteredChannels = notifyDB.notification.channelIds.filter( + id => id !== channelData.id + ); + if (filteredChannels.length === 0) { + await client.session.twitchConfig.delete({ + userId: user.id + }); + delete client.twitch.notifyList[user.id]; + } else { + await client.session.twitchConfig.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}>.` + }); +}; + +export const handleTwitchList: SetHandler = async interaction => { + if (!checkTwitchEnabled()) { + return await interaction.editReply({ + content: + ':warning: Twitch features are currently disabled in configuration.' + }); + } + const { client } = container; + const guildId = interaction.guildId!; + const guildDB = await client.session.guildData.getGuild({ + id: guildId + }); + if (!guildDB?.guild || guildDB.guild.notifyList.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: guildDB.guild.notifyList, + 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; +}; \ No newline at end of file diff --git a/apps/bot/src/lib/set/types.ts b/apps/bot/src/lib/set/types.ts new file mode 100644 index 000000000..fdd137c19 --- /dev/null +++ b/apps/bot/src/lib/set/types.ts @@ -0,0 +1,5 @@ +import type { ChatInputCommandInteraction } from 'discord.js'; + +export type SetHandler = ( + interaction: ChatInputCommandInteraction +) => Promise; \ No newline at end of file diff --git a/apps/bot/src/lib/set/view.ts b/apps/bot/src/lib/set/view.ts new file mode 100644 index 000000000..16e4f68f8 --- /dev/null +++ b/apps/bot/src/lib/set/view.ts @@ -0,0 +1,95 @@ +import { container } from '@sapphire/framework'; +import { EmbedBuilder } from 'discord.js'; +import { checkTwitchEnabled } from './twitch'; +import type { SetHandler } from './types'; + +export const handleView: SetHandler = async interaction => { + const { client } = container; + const guildId = interaction.guildId!; + const guildData = await client.session.guildData.getGuild({ + id: guildId + }); + const ticketConfig = await client.session.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] }); +}; \ No newline at end of file diff --git a/apps/bot/src/lib/set/volume.ts b/apps/bot/src/lib/set/volume.ts new file mode 100644 index 000000000..08dfdd02f --- /dev/null +++ b/apps/bot/src/lib/set/volume.ts @@ -0,0 +1,13 @@ +import { container } from '@sapphire/framework'; +import type { SetHandler } from './types'; + +export const handleDefaultVolume: SetHandler = async interaction => { + const volume = interaction.options.getInteger('volume', true); + await container.client.session.guildData.updateVolume({ + guildId: interaction.guildId!, + volume + }); + return await interaction.editReply({ + content: `:white_check_mark: Default playback volume for this server set to **${volume}%**.` + }); +}; \ No newline at end of file diff --git a/apps/bot/src/lib/set/welcome.ts b/apps/bot/src/lib/set/welcome.ts new file mode 100644 index 000000000..c4c70f5be --- /dev/null +++ b/apps/bot/src/lib/set/welcome.ts @@ -0,0 +1,82 @@ +import { container } from '@sapphire/framework'; +import type { TextChannel } from 'discord.js'; +import type { SetHandler } from './types'; + +export const handleWelcomeChannel: SetHandler = async interaction => { + const channel = interaction.options.getChannel('channel', true); + await container.client.session.welcomeMessages.setChannel({ + guildId: interaction.guildId!, + channelId: channel.id + }); + return await interaction.editReply({ + content: `:white_check_mark: Welcome messages will now be sent in <#${channel.id}>.` + }); +}; + +export const handleWelcomeMessage: SetHandler = async interaction => { + const message = interaction.options.getString('message', true); + await container.client.session.welcomeMessages.setMessage({ + guildId: interaction.guildId!, + message + }); + return await interaction.editReply({ + content: `:white_check_mark: Custom welcome message updated!\n\n**Preview:**\n> ${message}` + }); +}; + +export const handleWelcomeToggle: SetHandler = async interaction => { + const enabled = interaction.options.getBoolean('enabled', true); + await container.client.session.welcomeMessages.toggle({ + guildId: interaction.guildId!, + status: enabled + }); + return await interaction.editReply({ + content: `:white_check_mark: Welcome message system is now **${ + enabled ? 'ENABLED' : 'DISABLED' + }**.` + }); +}; + +export const handleWelcomeTest: SetHandler = async interaction => { + const guildId = interaction.guildId!; + const guildData = await container.client.session.guildData.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}>!` + }); +}; \ No newline at end of file diff --git a/apps/bot/src/lib/structures/CommandHelp.ts b/apps/bot/src/lib/structures/CommandHelp.ts new file mode 100644 index 000000000..171ca4f07 --- /dev/null +++ b/apps/bot/src/lib/structures/CommandHelp.ts @@ -0,0 +1,28 @@ +import { isCommandNameGloballyDisabled } from '../../preconditions/isCommandDisabled'; + +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..74a5e1f2d 100644 --- a/apps/bot/src/lib/structures/ExtendedClient.ts +++ b/apps/bot/src/lib/structures/ExtendedClient.ts @@ -2,8 +2,8 @@ import { SapphireClient } from '@sapphire/framework'; import '@sapphire/plugin-hmr/register'; import { QueueClient } from '../music/classes/QueueClient'; import Redis from 'ioredis'; +import { PrismaClient } from '@prisma/client'; import { - GatewayDispatchEvents, IntentsBitField, NewsChannel, TextChannel, @@ -13,10 +13,15 @@ import { deletePlayerEmbed } from '../music/buttonsCollector'; import type { ClientTwitchExtension } from './../../lib/twitch/twitchAPI-types'; import { TwitchAPI } from '../twitch/twitchAPI'; import Logger from '../logger'; +import type { TriviaSession } from '../music/classes/TriviaSession'; +import { SessionManager } from '../session/SessionManager'; export class ExtendedClient extends SapphireClient { readonly music: QueueClient; + readonly prisma: PrismaClient; + readonly session: SessionManager; leaveTimers: { [key: string]: NodeJS.Timeout }; + triviaSessions: Map = new Map(); twitch: ClientTwitchExtension = { api: new TwitchAPI( process.env.TWITCH_CLIENT_ID, @@ -47,38 +52,44 @@ export class ExtendedClient extends SapphireClient { } }); + this.prisma = new PrismaClient(); + this.session = new SessionManager(this.prisma); + 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 - }) + redis: process.env.REDIS_URL + ? new Redis(process.env.REDIS_URL) + : 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 - } - }); - - this.ws.on(GatewayDispatchEvents.VoiceServerUpdate, async data => { - await this.music.handleVoiceUpdate(data); + clientId: process.env.DISCORD_CLIENT_ID }); - 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) { @@ -120,16 +131,19 @@ export type MessageChannel = TextChannel | ThreadChannel | NewsChannel | null; declare module '@sapphire/framework' { interface SapphireClient { readonly music: QueueClient; + readonly prisma: PrismaClient; + readonly session: SessionManager; 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..a782026d7 --- /dev/null +++ b/apps/bot/src/lib/structures/HelpRegistry.ts @@ -0,0 +1,113 @@ +import { container } from '@sapphire/framework'; +import { isCommandNameGloballyDisabled } from '../../preconditions/isCommandDisabled'; +import type { CommandHelp } from './CommandHelp'; + +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/notifyChannels.ts b/apps/bot/src/lib/twitch/notifyChannels.ts index 3ad077b36..52e450825 100644 --- a/apps/bot/src/lib/twitch/notifyChannels.ts +++ b/apps/bot/src/lib/twitch/notifyChannels.ts @@ -3,7 +3,6 @@ import type { TwitchGame, TwitchStream } from './twitchAPI-types'; import { TwitchEmbed } from './TwitchEmbed'; import { container } from '@sapphire/framework'; import type { Message } from 'discord.js'; -import { trpcNode } from '../../trpc'; import Logger from '../logger'; // Twitch ids are non changeable, usernames are not good for reference @@ -108,7 +107,7 @@ export async function notify(query: string[]) { client.twitch.notifyList[entry].messageSent = true; // Update DataBase - await trpcNode.twitch.updateNotificationStatus.mutate({ + client.session.twitchConfig.updateNotificationStatus({ userId: entry, sent: true, live: true @@ -204,7 +203,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({ + client.session.twitchConfig.updateNotificationStatus({ userId: entry, sent: false, live: false @@ -232,3 +231,4 @@ export async function notify(query: string[]) { }); } } + diff --git a/apps/bot/src/lib/twitch/twitchAPI.ts b/apps/bot/src/lib/twitch/twitchAPI.ts index 48ccbb158..e91add38d 100644 --- a/apps/bot/src/lib/twitch/twitchAPI.ts +++ b/apps/bot/src/lib/twitch/twitchAPI.ts @@ -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..e4faeac66 100644 --- a/apps/bot/src/listeners/guild/guildCreate.ts +++ b/apps/bot/src/listeners/guild/guildCreate.ts @@ -1,7 +1,6 @@ import { ApplyOptions } from '@sapphire/decorators'; import { Listener, type ListenerOptions } from '@sapphire/framework'; import type { Guild } from 'discord.js'; -import { trpcNode } from '../../trpc'; @ApplyOptions({ name: 'guildCreate' @@ -10,15 +9,17 @@ export class GuildCreateListener extends Listener { public override async run(guild: Guild): Promise { const owner = await guild.fetchOwner(); - await trpcNode.user.create.mutate({ + this.container.client.session.users.create({ id: owner.id, name: owner.user.username }); - await trpcNode.guild.create.mutate({ + this.container.client.session.guildData.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..b4be4b4aa 100644 --- a/apps/bot/src/listeners/guild/guildDelete.ts +++ b/apps/bot/src/listeners/guild/guildDelete.ts @@ -1,15 +1,15 @@ import { ApplyOptions } from '@sapphire/decorators'; import { Listener, type ListenerOptions } from '@sapphire/framework'; import type { Guild } from 'discord.js'; -import { trpcNode } from '../../trpc'; @ApplyOptions({ name: 'guildDelete' }) export class GuildDeleteListener extends Listener { public override async run(guild: Guild): Promise { - await trpcNode.guild.delete.mutate({ + this.container.client.session.guildData.delete({ id: guild.id }); } } + diff --git a/apps/bot/src/listeners/guild/guildMemberAdd.ts b/apps/bot/src/listeners/guild/guildMemberAdd.ts index 9d631084d..54422b08a 100644 --- a/apps/bot/src/listeners/guild/guildMemberAdd.ts +++ b/apps/bot/src/listeners/guild/guildMemberAdd.ts @@ -2,37 +2,55 @@ import { ApplyOptions } from '@sapphire/decorators'; import { Listener, type ListenerOptions } from '@sapphire/framework'; import type { GuildMember, TextChannel } from 'discord.js'; -import { trpcNode } from '../../trpc'; @ApplyOptions({ name: 'guildMemberAdd' }) export class GuildMemberListener extends Listener { public override async run(member: GuildMember): Promise { - const guildQuery = await trpcNode.guild.getGuild.query({ + this.container.client.session.members.create({ + guildId: member.guild.id, + userId: member.id + }); + + const { guild } = this.container.client.session.guildData.getGuild({ id: member.guild.id }); - if (!guildQuery || !guildQuery.guild) return; + if (!guild) return; const { welcomeMessage, welcomeMessageEnabled, welcomeMessageChannel } = - guildQuery.guild; - - if ( - !welcomeMessageEnabled || - !welcomeMessage || - !welcomeMessage.length || - !welcomeMessageChannel - ) { + guild; + + 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/guild/guildMemberRemove.ts b/apps/bot/src/listeners/guild/guildMemberRemove.ts new file mode 100644 index 000000000..c41be18ae --- /dev/null +++ b/apps/bot/src/listeners/guild/guildMemberRemove.ts @@ -0,0 +1,15 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, type ListenerOptions } from '@sapphire/framework'; +import type { GuildMember } from 'discord.js'; + +@ApplyOptions({ + name: 'guildMemberRemove' +}) +export class GuildMemberRemoveListener extends Listener { + public override async run(member: GuildMember): Promise { + this.container.client.session.clearUserGuildData( + member.guild.id, + member.id + ); + } +} \ No newline at end of file diff --git a/apps/bot/src/listeners/interaction/ticketButtonListener.ts b/apps/bot/src/listeners/interaction/ticketButtonListener.ts new file mode 100644 index 000000000..244338206 --- /dev/null +++ b/apps/bot/src/listeners/interaction/ticketButtonListener.ts @@ -0,0 +1,325 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Events, Listener, type ListenerOptions } from '@sapphire/framework'; +import { + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ChannelType, + EmbedBuilder, + TextChannel, + ThreadAutoArchiveDuration, + ThreadChannel +} from 'discord.js'; +import type { Interaction } from 'discord.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 = this.container.client.session.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 + this.container.client.session.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 + this.container.client.session.tickets.closeTicket({ + threadId: thread.id + }); + + // Query guild ticket configuration to check transcript channel + const ticketConfig = this.container.client.session.tickets.getConfig({ + guildId: guild.id + }); + + 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/musicSongPlayMessage.ts b/apps/bot/src/listeners/music/musicSongPlayMessage.ts index 19a9cb83d..21ab90ee6 100644 --- a/apps/bot/src/listeners/music/musicSongPlayMessage.ts +++ b/apps/bot/src/listeners/music/musicSongPlayMessage.ts @@ -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..cda9427b9 100644 --- a/apps/bot/src/listeners/music/musicSongSkipNotify.ts +++ b/apps/bot/src/listeners/music/musicSongSkipNotify.ts @@ -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..d4a5ea62a 100644 --- a/apps/bot/src/listeners/tempchannels/voiceStateUpdate.ts +++ b/apps/bot/src/listeners/tempchannels/voiceStateUpdate.ts @@ -1,7 +1,6 @@ import { ApplyOptions } from '@sapphire/decorators'; -import { Listener, ListenerOptions } from '@sapphire/framework'; +import { Listener, ListenerOptions, container } from '@sapphire/framework'; import type { VoiceChannel, VoiceState } from 'discord.js'; -import { trpcNode } from '../../trpc'; import { ChannelType } from 'discord.js'; @ApplyOptions({ @@ -12,7 +11,7 @@ export class VoiceStateUpdateListener extends Listener { oldState: VoiceState, newState: VoiceState ): Promise { - const { guild: guildDB } = await trpcNode.guild.getGuild.query({ + const { guild: guildDB } = container.client.session.guildData.getGuild({ id: newState.guild.id }); @@ -21,10 +20,11 @@ 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({ - guildId: newState.guild.id, - ownerId: newState.member.id - }); + const { tempChannel } = + container.client.session.hubChannels.getTempChannel({ + guildId: newState.guild.id, + ownerId: newState.member.id + }); // user entered hub channel but he already has a temp channel, so move him there if (tempChannel) { await newState.setChannel(tempChannel.id); @@ -52,7 +52,7 @@ export class VoiceStateUpdateListener extends Listener { ] }); - await trpcNode.hub.createTempChannel.mutate({ + container.client.session.hubChannels.createTempChannel({ guildId: newState.guild.id, ownerId: newState.member.id, channelId: channel.id @@ -60,10 +60,11 @@ export class VoiceStateUpdateListener extends Listener { await newState.member.voice.setChannel(channel); } else { - const { tempChannel } = await trpcNode.hub.getTempChannel.query({ - guildId: newState.guild.id, - ownerId: newState.member.id - }); + const { tempChannel } = + container.client.session.hubChannels.getTempChannel({ + guildId: newState.guild.id, + ownerId: newState.member.id + }); if (!tempChannel) return; if (tempChannel.id === newState.channelId) return; @@ -75,7 +76,7 @@ export class VoiceStateUpdateListener extends Listener { Promise.all([ channel.delete(), - trpcNode.hub.deleteTempChannel.mutate({ + container.client.session.hubChannels.deleteTempChannel({ channelId: tempChannel.id }) ]); @@ -88,7 +89,7 @@ export class VoiceStateUpdateListener extends Listener { } async function deleteChannel(state: VoiceState) { - const { tempChannel } = await trpcNode.hub.getTempChannel.query({ + const { tempChannel } = container.client.session.hubChannels.getTempChannel({ guildId: state.guild.id, ownerId: state.member!.id }); @@ -96,9 +97,9 @@ async function deleteChannel(state: VoiceState) { if (tempChannel) { Promise.all([ state.channel?.delete(), - trpcNode.hub.deleteTempChannel.mutate({ + container.client.session.hubChannels.deleteTempChannel({ channelId: tempChannel.id }) ]); } -} +} \ No newline at end of file diff --git a/apps/bot/src/preconditions/isCommandDisabled.ts b/apps/bot/src/preconditions/isCommandDisabled.ts index e33016911..e353c7619 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 { container } from '@sapphire/framework'; +import { env } from '../env'; + +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 isLavaEnabled = + (env.LAVA_ENABLED || process.env.LAVA_ENABLED)?.toLowerCase() === 'true'; + const isGifsEnabled = + (env.GIFS_ENABLED || process.env.GIFS_ENABLED)?.toLowerCase() !== 'false'; + const isTwitchEnabled = + (env.TWITCH_ENABLED || process.env.TWITCH_ENABLED)?.toLowerCase() !== + 'false'; + const isNewsEnabled = + (env.NEWS_ENABLED || process.env.NEWS_ENABLED)?.toLowerCase() !== 'false'; + // IGDB utilizes Twitch API credentials โ€” respects IGDB_ENABLED if set, otherwise follows TWITCH_ENABLED + const rawIgdb = env.IGDB_ENABLED || process.env.IGDB_ENABLED; + const isIgdbEnabled = + rawIgdb !== undefined ? rawIgdb.toLowerCase() !== 'false' : isTwitchEnabled; + + const name = commandOrCategoryName.toLowerCase(); + + // 1. Direct Category Checks + if (!isLavaEnabled && name === 'music') return true; + if (!isGifsEnabled && name === 'gifs') return true; + if (!isTwitchEnabled && 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 (!isLavaEnabled && category === 'music') return true; + if (!isGifsEnabled && category === 'gifs') return true; + if (!isTwitchEnabled && category === 'twitch') return true; + if (!isNewsEnabled && cmd.name === 'news') return true; + if ((!isIgdbEnabled || !isTwitchEnabled) && cmd.name === 'game-search') + return true; + } + + return false; +} @ApplyOptions({ name: 'isCommandDisabled' @@ -16,20 +68,67 @@ 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 data = + this.container.client.session.commands.getDisabledCommands({ + guildId: guildID + }); + 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(); } } @@ -39,3 +138,4 @@ declare module '@sapphire/framework' { isCommandDisabled: never; } } + 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..7b8bd36b8 100644 --- a/apps/bot/src/preconditions/playlistExists.ts +++ b/apps/bot/src/preconditions/playlistExists.ts @@ -5,7 +5,6 @@ import { PreconditionOptions } from '@sapphire/framework'; import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; -import { trpcNode } from '../trpc'; @ApplyOptions({ name: 'playlistExists' @@ -18,8 +17,9 @@ export class PlaylistExists extends Precondition { const guildMember = interaction.member as GuildMember; - const playlist = await trpcNode.playlist.getPlaylist.query({ + const { playlist } = this.container.client.session.playlists.getPlaylist({ name: playlistName, + guildId: interaction.guildId ?? '', userId: guildMember.id }); @@ -27,7 +27,7 @@ export class PlaylistExists extends Precondition { ? this.ok() : this.error({ message: `You have no playlist named **${playlistName}**` - }); + }); } } @@ -36,3 +36,4 @@ declare module '@sapphire/framework' { playlistExists: never; } } + diff --git a/apps/bot/src/preconditions/playlistNotDuplicate.ts b/apps/bot/src/preconditions/playlistNotDuplicate.ts index 8a1159d34..a3c96ecfa 100644 --- a/apps/bot/src/preconditions/playlistNotDuplicate.ts +++ b/apps/bot/src/preconditions/playlistNotDuplicate.ts @@ -5,7 +5,6 @@ import { PreconditionOptions } from '@sapphire/framework'; import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; -import { trpcNode } from '../trpc'; @ApplyOptions({ name: 'playlistNotDuplicate' @@ -19,8 +18,9 @@ export class PlaylistNotDuplicate extends Precondition { const guildMember = interaction.member as GuildMember; try { - const playlist = await trpcNode.playlist.getPlaylist.query({ + const { playlist } = this.container.client.session.playlists.getPlaylist({ name: playlistName, + guildId: interaction.guildId ?? '', userId: guildMember.id }); @@ -40,3 +40,4 @@ declare module '@sapphire/framework' { playlistNotDuplicate: never; } } + diff --git a/apps/bot/src/preconditions/userInDB.ts b/apps/bot/src/preconditions/userInDB.ts index 1f31f23f6..893fedc26 100644 --- a/apps/bot/src/preconditions/userInDB.ts +++ b/apps/bot/src/preconditions/userInDB.ts @@ -5,7 +5,6 @@ import { PreconditionOptions } from '@sapphire/framework'; import type { ChatInputCommandInteraction, GuildMember } from 'discord.js'; -import { trpcNode } from '../trpc'; import Logger from '../lib/logger'; @ApplyOptions({ @@ -18,7 +17,7 @@ export class UserInDB extends Precondition { const guildMember = interaction.member as GuildMember; try { - const user = await trpcNode.user.create.mutate({ + const user = this.container.client.session.users.create({ id: guildMember.id, name: guildMember.user.username }); 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..6a1a4cd7c 100644 --- a/apps/bot/tsconfig.json +++ b/apps/bot/tsconfig.json @@ -18,3 +18,5 @@ "include": ["src", "scripts", "src/env.ts"], "exclude": ["node_modules"] } + + diff --git a/apps/dashboard/.eslintrc.cjs b/apps/dashboard/.eslintrc.cjs new file mode 100644 index 000000000..4d385cd42 --- /dev/null +++ b/apps/dashboard/.eslintrc.cjs @@ -0,0 +1,9 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + extends: [ + '@master-bot/eslint-config/base', + '@master-bot/eslint-config/nextjs', + '@master-bot/eslint-config/react' + ] +}; diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index cc4052672..83cb3f520 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -1,28 +1,49 @@ -# Create T3 App +# ๐ŸŒ Master-Bot Web Dashboard -This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. +The official web management portal and control center for **Master-Bot**, built with **Next.js 15 (App Router)**, **React 18**, **tRPC v11**, **NextAuth.js v5 beta**, **Prisma ORM** (SQLite), and **Tailwind CSS**. -## 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. +## โšก Features & Control Panels -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. +- **๐Ÿ” Discord OAuth Authentication:** Secure login via NextAuth.js with Discord OAuth2, user upsert by Discord ID, and avatar synchronization. +- **๐Ÿ“Š Server Hub (`/dashboard`):** Server picker for every server where the bot is present. +- **๐Ÿ‘‹ Welcome Greetings (`/dashboard/[server_id]/welcome-message`):** Channel picker, template editor, toggle, and live embed preview. +- **๐Ÿ“œ Audit & Event Logging (`/dashboard/[server_id]/log-channel`):** Master toggle, channel picker, and a switchboard of **20 event triggers** across Members, Messages, Channels, Roles, Voice, and Moderation. +- **๐ŸŽซ Support Ticket System (`/dashboard/[server_id]/tickets`):** Ticket toggle, channel selectors for the panel and transcripts, and custom greeting editing. +- **โฐ Reminders (`/dashboard/reminders`):** Personal and server-wide scheduled reminders with live countdowns and status badges. +- **๐ŸŽ›๏ธ Command Management (`/dashboard/[server_id]/commands/[command_id]`):** Per-command info and toggles. +- **๐ŸŽต Music (`/dashboard/music`):** Global audio and queue settings. +- **๐Ÿ“ฃ Broadcast (`/dashboard/broadcast`):** Rich embed broadcaster with live Discord-style preview. +- **๐Ÿ”Œ Integrations (`/dashboard/integrations`):** External service connections and credentials. +- **๐Ÿ–ฅ๏ธ System Telemetry (`/dashboard/system`):** Runtime health, uptime, and diagnostics. -- [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 +## ๐Ÿ› ๏ธ Tech Stack -To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: +- **Framework:** [Next.js 15](https://nextjs.org/) (App Router, Server Actions, RSC) +- **API & State:** [tRPC v11](https://trpc.io/) & [@tanstack/react-query v5](https://tanstack.com/query) +- **Auth:** [NextAuth.js v5 beta](https://authjs.dev/) (`@auth/prisma-adapter`) via `@master-bot/auth` +- **Database:** [Prisma ORM](https://www.prisma.io/) with **SQLite** (shared with the bot through `@master-bot/db`) +- **UI & Styling:** [Tailwind CSS](https://tailwindcss.com/), Radix UI primitives, custom UI components -- [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! +## ๐Ÿš€ Running Locally -## How do I deploy this? +From the project root: -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. +```bash +# Development mode (launches Bot, Dashboard, and Lavalink) +pnpm dev + +# Or launch only the dashboard +pnpm --filter @master-bot/dashboard dev +``` + +> โš ๏ธ The dashboard and bot **must share the same `db.sqlite`** โ€” run them from the same directory or mount one persistent volume in containers. + +## ๐Ÿ“š Wiki + +See the [Web Dashboard](https://github.com/galnir/Master-Bot/wiki/Dashboard) page for the full architecture and studios overview. \ No newline at end of file diff --git a/apps/dashboard/next-env.d.ts b/apps/dashboard/next-env.d.ts index 4f11a03dc..1b3be0840 100644 --- a/apps/dashboard/next-env.d.ts +++ b/apps/dashboard/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index a793ff12c..922e0d01b 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -6,12 +6,19 @@ import '@master-bot/auth/env.mjs'; const config = { reactStrictMode: true, /** Enables hot reloading for local packages without a build step */ - transpilePackages: ['@master-bot/api', '@master-bot/auth', '@master-bot/db'], + transpilePackages: ['@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'] + remotePatterns: [ + { + protocol: 'https', + hostname: 'cdn.discordapp.com', + port: '', + pathname: '/**' + } + ] } }; diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index f362b105e..fb4e9aa34 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -4,63 +4,54 @@ "private": true, "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", + "lint": "pnpm with-env next lint", + "lint:fix": "pnpm with-env next lint --fix", "start": "pnpm with-env next start", "type-check": "tsc --noEmit", "with-env": "dotenv -e ../../.env --" }, "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", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-select": "^2.3.7", + "@radix-ui/react-slot": "^1.3.3", + "@radix-ui/react-switch": "^1.3.7", + "@radix-ui/react-toast": "^1.2.23", + "@t3-oss/env-nextjs": "^0.13.11", + "@tanstack/react-query": "^5.102.8", + "@tanstack/react-query-devtools": "^5.102.8", + "@trpc/client": "^11.18.0", + "@trpc/next": "^11.18.0", + "@trpc/react-query": "^11.18.0", + "@trpc/server": "^11.18.0", + "axios": "^1.20.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "discord-api-types": "^0.37.119", + "lucide-react": "^1.35.0", + "next": "^15.2.0", + "next-themes": "^0.4.6", + "react": "^18.3.1", + "react-dom": "^18.3.1", "superjson": "1.13.3", "tailwind-merge": "^2.0.0", "tailwindcss-animate": "^1.0.7", - "zod": "^3.22.4" + "zod": "^3.24.4" }, "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": "^20.19.43", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "autoprefixer": "^10.5.4", + "dotenv-cli": "^7.4.4", + "eslint": "^8.57.1", + "ioredis": "^5.6.1", + "postcss": "^8.5.26", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3" } } diff --git a/apps/dashboard/src/app/api/trpc/[trpc]/route.ts b/apps/dashboard/src/app/api/trpc/[trpc]/route.ts index e2997e5d8..6ddded2d6 100644 --- a/apps/dashboard/src/app/api/trpc/[trpc]/route.ts +++ b/apps/dashboard/src/app/api/trpc/[trpc]/route.ts @@ -1,12 +1,14 @@ import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; -import { appRouter, createTRPCContext } from '@master-bot/api'; + +import { createTRPCContext } from '~/server/context'; +import { appRouter } from '~/server/root'; const handler = (req: Request) => fetchRequestHandler({ req, router: appRouter, endpoint: '/api/trpc', - createContext: createTRPCContext + createContext: () => createTRPCContext({ req }) }); -export { handler as GET, handler as POST }; +export { handler as GET, handler as POST }; \ No newline at end of file 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 index 81b07273d..99ae88c4b 100644 --- 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 @@ -5,6 +5,7 @@ import { ApplicationCommandPermissionType } from 'discord-api-types/v10'; import { useState } from 'react'; +import { useParams } from 'next/navigation'; import { api } from '~/utils/api'; import { useToast } from '~/components/ui/use-toast'; import { @@ -21,14 +22,12 @@ interface Role { color: number; } -export default function CommandPage({ - params -}: { - params: { +export default function CommandPage() { + const params = useParams<{ server_id: string; command_id: string; - }; -}) { + }>(); + const { data, isLoading } = api.command.getCommandAndGuildChannels.useQuery( { guildId: params.server_id, @@ -128,8 +127,8 @@ const PermissionsEdit = ({ type: selectedRadio }, { - onSuccess: async () => { - await utils.command.getCommandAndGuildChannels.invalidate(); + onSuccess: () => { + void utils.command.getCommandAndGuildChannels.invalidate(); setDisableSave(false); toast({ title: 'Permissions updated' diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/actions.ts b/apps/dashboard/src/app/dashboard/[server_id]/commands/actions.ts index 4d21ba6bc..54dbe00e8 100644 --- a/apps/dashboard/src/app/dashboard/[server_id]/commands/actions.ts +++ b/apps/dashboard/src/app/dashboard/[server_id]/commands/actions.ts @@ -20,29 +20,26 @@ export async function toggleCommand( 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 - } - } - }); + let disabledCommands: string[] = []; + try { + disabledCommands = JSON.parse(guild.disabledCommands || '[]'); + } catch { + disabledCommands = []; } + // newStatus === enabled: remove from the disabled list, otherwise add it + const updated = newStatus + ? disabledCommands.filter(id => id !== commandId) + : [...disabledCommands, commandId]; + + await prisma.guild.update({ + where: { + id: guildId + }, + data: { + disabledCommands: JSON.stringify(updated) + } + }); + revalidatePath(`/dashboard/${guildId}/commands`); -} +} \ No newline at end of file diff --git a/apps/dashboard/src/app/dashboard/[server_id]/commands/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/commands/page.tsx index cc851fd97..12baaef2b 100644 --- a/apps/dashboard/src/app/dashboard/[server_id]/commands/page.tsx +++ b/apps/dashboard/src/app/dashboard/[server_id]/commands/page.tsx @@ -3,75 +3,349 @@ import { prisma } from '@master-bot/db'; import type { APIApplicationCommand } from 'discord-api-types/v10'; import CommandToggleSwitch from './toggle-command'; import Link from 'next/link'; +import { + Music, + Film, + Tv, + Newspaper, + Gamepad2, + Sparkles, + SlidersHorizontal, + Info, + Shield +} from 'lucide-react'; 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}` + try { + const response = await fetch( + `https://discordapp.com/api/applications/${env.DISCORD_CLIENT_ID}/commands`, + { + headers: { + Authorization: `Bot ${env.DISCORD_TOKEN}` + }, + next: { revalidate: 60 } } + ); + + if (!response.ok) { + return []; } - ); - return (await response.json()) as APIApplicationCommand[]; + return (await response.json()) as APIApplicationCommand[]; + } catch (e) { + console.error('Error fetching application commands:', e); + return []; + } +} + +// Category Command Rosters +const MUSIC_COMMANDS = [ + 'play', + 'pause', + 'resume', + 'skip', + 'skipto', + 'queue', + 'volume', + 'bassboost', + 'nightcore', + 'vaporwave', + 'karaoke', + 'seek', + 'shuffle', + 'remove', + 'leave', + 'lyrics', + 'move', + 'create-playlist', + 'delete-playlist', + 'display-playlist', + 'my-playlists', + 'save-to-playlist', + 'remove-from-playlist' +]; + +const GIF_COMMANDS = [ + 'amongus', + 'anime', + 'baka', + 'cat', + 'doggo', + 'gif', + 'gintama', + 'hug', + 'jojo', + 'slap', + 'waifu' +]; + +const TWITCH_COMMANDS = [ + 'add-streamer', + 'remove-streamer', + 'show-announcer-list', + 'twitch-status' +]; + +const NEWS_COMMANDS = ['news']; + +const MODERATION_COMMANDS = ['ban', 'kick', 'slowmode', 'timeout', 'purge']; + +const GAME_COMMANDS = [ + 'game-search', + 'games', + '8ball', + 'rockpaperscissors', + 'speedrun' +]; + +interface CommandCategoryDef { + id: string; + title: string; + description: string; + icon: React.ComponentType<{ className?: string }>; + isGloballyEnabled: boolean; + envFlag: string; + matchCommand: (name: string) => boolean; } export default async function CommandsPage({ params }: { - params: { server_id: string }; + params: Promise<{ server_id: string }>; }) { - // get disabled commands + const { server_id } = await params; + const guild = await prisma.guild.findUnique({ - where: { id: params.server_id }, + where: { id: server_id }, select: { disabledCommands: true } }); - const commands = await getApplicationCommands(); + const disabledCommands: string[] = guild + ? (JSON.parse(guild.disabledCommands || '[]') as string[]) + : []; + + const rawCommands = await getApplicationCommands(); + + // Read environment toggles + const isLavaEnabled = + (env.LAVA_ENABLED ?? process.env.LAVA_ENABLED)?.toLowerCase() === 'true'; + const isGifsEnabled = + (env.GIFS_ENABLED ?? process.env.GIFS_ENABLED)?.toLowerCase() !== 'false'; + const isTwitchEnabled = + (env.TWITCH_ENABLED ?? process.env.TWITCH_ENABLED)?.toLowerCase() !== + 'false'; + const isNewsEnabled = + (env.NEWS_ENABLED ?? process.env.NEWS_ENABLED)?.toLowerCase() !== 'false'; + const rawIgdb = env.IGDB_ENABLED ?? process.env.IGDB_ENABLED; + const isIgdbEnabled = + rawIgdb !== undefined ? rawIgdb.toLowerCase() !== 'false' : isTwitchEnabled; + + const categories: CommandCategoryDef[] = [ + { + id: 'moderation', + title: 'Moderation & Management', + description: + 'Server management tools, member bans, kicks, timeouts, slowmode, and message purging.', + icon: Shield, + isGloballyEnabled: true, + envFlag: '', + matchCommand: (name: string) => + MODERATION_COMMANDS.includes(name.toLowerCase()) + }, + { + id: 'music', + title: 'Music & Audio', + description: + 'Audio playback, playlist management, queue filters, and volume controls.', + icon: Music, + isGloballyEnabled: isLavaEnabled, + envFlag: 'LAVA_ENABLED', + matchCommand: (name: string) => + MUSIC_COMMANDS.includes(name.toLowerCase()) + }, + { + id: 'gifs', + title: 'GIFs & Anime Reactions', + description: + 'Interactive animated gifs, anime reactions, and social emotes.', + icon: Film, + isGloballyEnabled: isGifsEnabled, + envFlag: 'GIFS_ENABLED', + matchCommand: (name: string) => GIF_COMMANDS.includes(name.toLowerCase()) + }, + { + id: 'twitch', + title: 'Twitch & Stream Alerts', + description: + 'Twitch streamer monitors, live notification subscriptions, and status checks.', + icon: Tv, + isGloballyEnabled: isTwitchEnabled, + envFlag: 'TWITCH_ENABLED', + matchCommand: (name: string) => + TWITCH_COMMANDS.includes(name.toLowerCase()) + }, + { + id: 'news', + title: 'News & Headlines', + description: 'Global news searches and latest headline digests.', + icon: Newspaper, + isGloballyEnabled: isNewsEnabled, + envFlag: 'NEWS_ENABLED', + matchCommand: (name: string) => NEWS_COMMANDS.includes(name.toLowerCase()) + }, + { + id: 'games', + title: 'Games & Entertainment', + description: + 'IGDB game database search, minigames, 8ball, and speedrun records.', + icon: Gamepad2, + isGloballyEnabled: true, + envFlag: 'IGDB_ENABLED / TWITCH_ENABLED', + matchCommand: (name: string) => GAME_COMMANDS.includes(name.toLowerCase()) + }, + { + id: 'general', + title: 'General & Utilities', + description: + 'Information lookup, server utilities, translation, dictionary, and miscellaneous tools.', + icon: Sparkles, + isGloballyEnabled: true, + envFlag: '', + matchCommand: (name: string) => + !MODERATION_COMMANDS.includes(name.toLowerCase()) && + !MUSIC_COMMANDS.includes(name.toLowerCase()) && + !GIF_COMMANDS.includes(name.toLowerCase()) && + !TWITCH_COMMANDS.includes(name.toLowerCase()) && + !NEWS_COMMANDS.includes(name.toLowerCase()) && + !GAME_COMMANDS.includes(name.toLowerCase()) + } + ]; + + // Filter out categories that are globally disabled via ENV + const activeCategories = categories.filter(cat => cat.isGloballyEnabled); return ( -
-

- Enable / Disable Commands Panel -

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

+ + Command Management Panel +

+

+ Enable or disable slash commands for this server and configure custom + role permissions. +

+
+ + {rawCommands && rawCommands.length > 0 && activeCategories.length > 0 ? ( +
+ {activeCategories.map(category => { + const categoryCommands = rawCommands.filter(cmd => { + if (!category.matchCommand(cmd.name)) return false; + // Specific check for IGDB game-search inside games category + if ( + cmd.name.toLowerCase() === 'game-search' && + (!isIgdbEnabled || !isTwitchEnabled) + ) { + return false; + } + return true; + }); + + if (categoryCommands.length === 0) return null; + return (
-
- -

{command.name}

- -

{command.description}

+ {/* Category Header */} +
+
+
+ +
+
+

+ {category.title} +

+

+ {category.description} +

+
+
+ +
+ + {categoryCommands.length} commands + +
-
- + + {/* Category Command List */} +
+ {categoryCommands.map(command => { + const isServerDisabled = + disabledCommands.includes(command.id) ?? false; + const isCommandEnabled = !isServerDisabled; + + return ( +
+
+
+ + /{command.name} + + + {/* Status Badge */} + {isServerDisabled ? ( + + Disabled (Guild) + + ) : ( + + Active + + )} +
+ +

+ {command.description || 'No description available'} +

+
+ +
+ +
+
+ ); + })}
); })}
) : ( -
Error loading commands
+
+ +

+ No Active Commands Available +

+

+ All command categories are currently disabled by global + configuration or no commands are registered. +

+
)}
); 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 index ff5b8bd28..8a6ceb3f5 100644 --- a/apps/dashboard/src/app/dashboard/[server_id]/commands/toggle-command.tsx +++ b/apps/dashboard/src/app/dashboard/[server_id]/commands/toggle-command.tsx @@ -9,14 +9,32 @@ import { ToastAction } from '~/components/ui/toast'; export default function CommandToggleSwitch({ commandEnabled, serverId, - commandId + commandId, + globallyDisabled = false, + disabledReason }: { commandEnabled: boolean; serverId: string; commandId: string; + globallyDisabled?: boolean; + disabledReason?: string; }) { const { toast } = useToast(); + if (globallyDisabled) { + return ( +
+ +
+ ); + } + return ( ; children: React.ReactNode; }) { + const { server_id } = await params; const session = await auth(); - if (!session?.user) { + if (!session?.user?.discordId) { redirect('/'); } const guild = await prisma.guild.findUnique({ where: { - id: params.server_id, - ownerId: session.user.discordId + id: server_id } }); @@ -31,7 +31,7 @@ export default async function Layout({ return (
- +
diff --git a/apps/dashboard/src/app/dashboard/[server_id]/log-channel/actions.ts b/apps/dashboard/src/app/dashboard/[server_id]/log-channel/actions.ts new file mode 100644 index 000000000..f3ee321a1 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/log-channel/actions.ts @@ -0,0 +1,50 @@ +'use server'; + +import { prisma } from '@master-bot/db'; +import { revalidatePath } from 'next/cache'; + +export async function toggleLogChannel(status: boolean, server_id: string) { + await prisma.guild.update({ + where: { + id: server_id + }, + data: { + logChannelEnabled: status + } + }); + + revalidatePath(`/dashboard/${server_id}/log-channel`); + revalidatePath(`/dashboard/${server_id}`); +} + +export async function updateLogEvents(events: string[], server_id: string) { + await prisma.guild.update({ + where: { + id: server_id + }, + data: { + logEvents: JSON.stringify(events) + } + }); + + revalidatePath(`/dashboard/${server_id}/log-channel`); + revalidatePath(`/dashboard/${server_id}`); +} + +export async function setLogChannel( + channelId: string | null, + server_id: string +) { + await prisma.guild.update({ + where: { + id: server_id + }, + data: { + logChannel: channelId, + logChannelEnabled: Boolean(channelId) + } + }); + + revalidatePath(`/dashboard/${server_id}/log-channel`); + revalidatePath(`/dashboard/${server_id}`); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/log-channel/log-events-form.tsx b/apps/dashboard/src/app/dashboard/[server_id]/log-channel/log-events-form.tsx new file mode 100644 index 000000000..55883ad07 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/log-channel/log-events-form.tsx @@ -0,0 +1,367 @@ +'use client'; + +import { useState } from 'react'; +import { Switch } from '~/components/ui/switch'; +import { Button } from '~/components/ui/button'; +import { useToast } from '~/components/ui/use-toast'; +import { updateLogEvents } from './actions'; + +export interface LogCategory { + name: string; + description: string; + icon: string; + events: { + id: string; + label: string; + description: string; + }[]; +} + +export const LOG_CATEGORIES: LogCategory[] = [ + { + name: 'Member Events', + description: 'Track member join/leave and profile updates', + icon: '๐Ÿ‘ฅ', + events: [ + { + id: 'member_join', + label: 'Member Joined', + description: + 'Logs when a new member joins the server with account age and member count.' + }, + { + id: 'member_leave', + label: 'Member Left / Kicked', + description: 'Logs when a member leaves or is removed from the server.' + }, + { + id: 'member_role', + label: 'Member Roles Updated', + description: 'Logs when roles are added to or removed from a member.' + }, + { + id: 'member_nick', + label: 'Nickname Changed', + description: 'Logs member nickname changes.' + } + ] + }, + { + name: 'Message Events', + description: 'Monitor deleted, edited, and purged chat messages', + icon: '๐Ÿ’ฌ', + events: [ + { + id: 'message_delete', + label: 'Message Deleted', + description: + 'Logs deleted messages including text content and attachments.' + }, + { + id: 'message_edit', + label: 'Message Edited', + description: 'Logs before and after text when a message is modified.' + }, + { + id: 'message_purge', + label: 'Messages Purged / Cleaned', + description: 'Logs bulk message deletion events.' + } + ] + }, + { + name: 'Channel Events', + description: 'Track channel creations, deletions, and modifications', + icon: '๐Ÿ“', + events: [ + { + id: 'channel_create', + label: 'Channel Created', + description: + 'Logs when a new text, voice, or category channel is created.' + }, + { + id: 'channel_delete', + label: 'Channel Deleted', + description: 'Logs when a channel is removed from the server.' + }, + { + id: 'channel_update', + label: 'Channel Modified', + description: + 'Logs channel renames, topic changes, and permission edits.' + } + ] + }, + { + name: 'Role Events', + description: 'Track role creations, deletions, and permission updates', + icon: '๐Ÿ›ก๏ธ', + events: [ + { + id: 'role_create', + label: 'Role Created', + description: 'Logs when a new server role is created.' + }, + { + id: 'role_delete', + label: 'Role Deleted', + description: 'Logs when a server role is deleted.' + }, + { + id: 'role_update', + label: 'Role Updated', + description: 'Logs changes to role names, colors, and permissions.' + } + ] + }, + { + name: 'Voice Events', + description: 'Track member voice channel activity', + icon: '๐Ÿ”Š', + events: [ + { + id: 'voice_join', + label: 'Voice Channel Joined', + description: 'Logs when a member connects to a voice channel.' + }, + { + id: 'voice_leave', + label: 'Voice Channel Left', + description: 'Logs when a member disconnects from voice.' + }, + { + id: 'voice_move', + label: 'Voice Channel Switched', + description: + 'Logs when a member moves from one voice channel to another.' + } + ] + }, + { + name: 'Moderation Actions', + description: 'Audit kicks, bans, and timeouts executed by staff', + icon: 'โš–๏ธ', + events: [ + { + id: 'mod_ban', + label: 'Member Banned', + description: 'Logs when a user is banned from the server.' + }, + { + id: 'mod_unban', + label: 'Member Unbanned', + description: 'Logs when a user ban is revoked.' + }, + { + id: 'mod_timeout', + label: 'Member Timed Out', + description: 'Logs when a member is placed in or removed from timeout.' + }, + { + id: 'mod_kick', + label: 'Member Kicked', + description: 'Logs moderation kick actions.' + } + ] + } +]; + +export const ALL_EVENT_IDS = LOG_CATEGORIES.flatMap(c => + c.events.map(e => e.id) +); + +export default function LogEventsForm({ + guildId, + initialEvents +}: { + guildId: string; + initialEvents: string[]; +}) { + // If empty in DB on first load, default all to enabled for best initial UX + const [selectedEvents, setSelectedEvents] = useState( + initialEvents.length === 0 ? ALL_EVENT_IDS : initialEvents + ); + const [isSaving, setIsSaving] = useState(false); + const { toast } = useToast(); + + const handleToggleEvent = (eventId: string) => { + setSelectedEvents(prev => + prev.includes(eventId) + ? prev.filter(id => id !== eventId) + : [...prev, eventId] + ); + }; + + const handleToggleCategory = (category: LogCategory, enableAll: boolean) => { + const categoryIds = category.events.map(e => e.id); + setSelectedEvents(prev => { + if (enableAll) { + return Array.from(new Set([...prev, ...categoryIds])); + } else { + return prev.filter(id => !categoryIds.includes(id)); + } + }); + }; + + const handleEnableAllOverall = () => { + setSelectedEvents(ALL_EVENT_IDS); + }; + + const handleDisableAllOverall = () => { + setSelectedEvents([]); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + await updateLogEvents(selectedEvents, guildId); + toast({ + title: 'Log settings saved', + description: `Updated event triggers (${selectedEvents.length} of ${ALL_EVENT_IDS.length} active).` + }); + } catch { + toast({ + title: 'Error saving log settings', + description: 'Please try again later.', + variant: 'destructive' + }); + } finally { + setIsSaving(false); + } + }; + + return ( +
+ {/* Top action bar */} +
+
+

+ ๐Ÿ“Š Active Log Triggers: {selectedEvents.length} /{' '} + {ALL_EVENT_IDS.length} +

+

+ Select which specific Discord server events are dispatched to your + log channel. +

+
+
+ + + +
+
+ + {/* Category Cards */} +
+ {LOG_CATEGORIES.map(category => { + const activeCount = category.events.filter(e => + selectedEvents.includes(e.id) + ).length; + const allActive = activeCount === category.events.length; + + return ( +
+
+
+ {category.icon} +
+
+ {category.name} +
+

+ {category.description} +

+
+
+
+ + {activeCount}/{category.events.length} + + +
+
+ +
+ {category.events.map(event => { + const isChecked = selectedEvents.includes(event.id); + return ( +
+
+ +

+ {event.description} +

+
+ handleToggleEvent(event.id)} + /> +
+ ); + })} +
+
+ ); + })} +
+ + {/* Floating Bottom Action Bar */} +
+ + Remember to save your settings after making changes. + + +
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/log-channel/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/log-channel/page.tsx new file mode 100644 index 000000000..d8cf8dee3 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/log-channel/page.tsx @@ -0,0 +1,78 @@ +import { prisma } from '@master-bot/db'; +import LogChannelToggle from './switch'; +import LogChannelSet from './set-channel'; +import LogEventsForm from './log-events-form'; +import Link from 'next/link'; + +function getGuildById(id: string) { + return prisma.guild.findUnique({ + where: { + id + } + }); +} + +export default async function LogChannelPage({ + params +}: { + params: Promise<{ server_id: string }>; +}) { + const { server_id } = await params; + const guild = await getGuildById(server_id); + + if (!guild) { + return
Error loading guild
; + } + + return ( + <> +
+ + โ† Back to Server + +
+ +

Audit & Moderation Logging

+
+
+

+ Track server events, moderation actions, and audit updates +

+
+ System Status: + {guild.logChannelEnabled && guild.logChannel ? ( + + ๐ŸŸข Enabled + + ) : ( + + ๐Ÿ”ด Disabled + + )} + +
+
+ +
+ +
+ + {guild.logChannelEnabled && ( + + )} +
+ + ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/log-channel/set-channel.tsx b/apps/dashboard/src/app/dashboard/[server_id]/log-channel/set-channel.tsx new file mode 100644 index 000000000..9b9a4cf0b --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/log-channel/set-channel.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { api } from '~/utils/api'; +import { useState } from 'react'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '~/components/ui/select'; +import { Button } from '~/components/ui/button'; +import { useToast } from '~/components/ui/use-toast'; + +export default function LogChannelSet({ + guildId, + initialChannel +}: { + guildId: string; + initialChannel: string | null; +}) { + const { toast } = useToast(); + const [value, setValue] = useState(initialChannel ?? ''); + + const { data, isLoading } = api.channel.getAll.useQuery({ + guildId + }); + + const { mutate, isPending } = api.guild.setLogChannel.useMutation(); + + return ( +
+
+

+ ๐Ÿ“ข Target Log Channel +

+

+ Select the text channel where audit events, moderation actions, and + server logs will be dispatched. +

+
+ + {isLoading && !data ? ( +
Loading channels...
+ ) : ( +
+ + + +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/log-channel/switch.tsx b/apps/dashboard/src/app/dashboard/[server_id]/log-channel/switch.tsx new file mode 100644 index 000000000..384f47859 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/log-channel/switch.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useToast } from '~/components/ui/use-toast'; +import { Switch } from '~/components/ui/switch'; +import { toggleLogChannel } from './actions'; + +export default function LogChannelToggle({ + logChannelEnabled, + serverId +}: { + logChannelEnabled: boolean; + serverId: string; +}) { + const { toast } = useToast(); + + return ( +
+ { + void toggleLogChannel(!logChannelEnabled, serverId).then(() => { + toast({ + title: `Audit & log channel ${ + logChannelEnabled ? 'disabled' : 'enabled' + }` + }); + }); + }} + /> +
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/page.tsx index 3cbf57e73..70dec3104 100644 --- a/apps/dashboard/src/app/dashboard/[server_id]/page.tsx +++ b/apps/dashboard/src/app/dashboard/[server_id]/page.tsx @@ -1,7 +1,282 @@ -export default function ServerIndexPage() { +import Link from 'next/link'; +import { prisma } from '@master-bot/db'; +import { + Terminal, + MessageCircle, + Server, + CheckCircle2, + XCircle, + ScrollText, + LifeBuoy +} from 'lucide-react'; +import { Button } from '~/components/ui/button'; + +export default async function ServerIndexPage({ + params +}: { + params: Promise<{ server_id: string }>; +}) { + const { server_id } = await params; + + const guild = await prisma.guild.findUnique({ + where: { id: server_id }, + select: { + name: true, + id: true, + disabledCommands: true, + welcomeMessageEnabled: true, + logChannelEnabled: true, + logChannel: true, + ticketEnabled: true, + ticketChannel: true, + volume: true + } + }); + + if (!guild) { + return ( +
+

Server Not Found

+
+ ); + } + return ( -
-

Guild index page

+
+
+

+ + {guild.name} +

+

+ Server ID:{' '} + + {guild.id} + +

+
+ + {/* Quick Stats Grid */} +
+
+
+ + Slash Commands + + +
+
+ + {(JSON.parse(guild.disabledCommands || '[]') as string[]).length}{' '} + Disabled + +

+ All other commands enabled +

+
+
+ +
+
+ +
+
+ + Welcome Message + + +
+
+ {guild.welcomeMessageEnabled ? ( + <> + + + Active + + + ) : ( + <> + + + Inactive + + + )} +
+

+ {guild.welcomeMessageEnabled + ? 'Welcoming new members automatically' + : 'Disabled for this guild'} +

+
+ +
+
+ +
+
+ + Audit & Log Channel + + +
+
+ {guild.logChannelEnabled && guild.logChannel ? ( + <> + + + Active + + + ) : ( + <> + + + Inactive + + + )} +
+

+ {guild.logChannelEnabled && guild.logChannel + ? 'Routing moderation logs to channel' + : 'Logging is disabled'} +

+
+ +
+
+ +
+
+ + Support Tickets + + +
+
+ {guild.ticketEnabled && guild.ticketChannel ? ( + <> + + + Active + + + ) : ( + <> + + + Inactive + + + )} +
+

+ {guild.ticketEnabled && guild.ticketChannel + ? 'Thread-based ticket system ready' + : 'Ticket system is disabled'} +

+
+ +
+
+
+ + {/* Studio Quick Launchers */} +
+

+ Command Center Studios +

+
+ +
+

+ Audio & Music Studio +

+

+ Lavalink v4 queue & DSP +

+
+ + โ†’ + + + + +
+

+ Embed Broadcaster +

+

+ WYSIWYG announcements +

+
+ + โ†’ + + + + +
+

+ Twitch Integrations +

+

+ Live stream alerts +

+
+ + โ†’ + + + + +
+

+ Cluster Diagnostics +

+

+ Latency & telemetry metrics +

+
+ + โ†’ + + +
+
); } diff --git a/apps/dashboard/src/app/dashboard/[server_id]/reminders/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/reminders/page.tsx new file mode 100644 index 000000000..a5dae0721 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/reminders/page.tsx @@ -0,0 +1,59 @@ +import { auth } from '@master-bot/auth'; +import { prisma } from '@master-bot/db'; +import { redirect } from 'next/navigation'; +import { Bell } from 'lucide-react'; +import ReminderForm from '../../reminders/reminder-form'; +import RemindersList from '../../reminders/reminders-list'; + +export default async function ServerRemindersPage() { + const session = await auth(); + + if (!session?.user) { + redirect('/'); + } + + const discordId = (session.user as any).discordId || session.user.id; + const reminders = await prisma.reminder.findMany({ + where: { + userId: discordId + }, + select: { + id: true, + event: true, + description: true, + dateTime: true, + repeat: true + }, + orderBy: { + dateTime: 'asc' + } + }); + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

+ Reminders Manager +

+

+ Create and manage timed notifications with dynamic formatting tags + and real-time preview. +

+
+
+
+ + {/* Main Content */} +
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx b/apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx index 309ca904f..46393a9cf 100644 --- a/apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx +++ b/apps/dashboard/src/app/dashboard/[server_id]/sidebar.tsx @@ -1,37 +1,129 @@ +'use client'; + import Link from 'next/link'; -import { MessageCircle, ChevronRightSquare } from 'lucide-react'; +import { usePathname } from 'next/navigation'; +import { + LayoutDashboard, + Terminal, + MessageCircle, + FileText, + Ticket, + Bell, + Music2, + Send, + Layers, + Activity, + ArrowLeft +} 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 }) { + const pathname = usePathname(); + + const links = [ + { + href: `/dashboard/${server_id}`, + label: 'Overview', + icon: LayoutDashboard, + exact: true + }, + { + href: `/dashboard/${server_id}/commands`, + label: 'Commands', + icon: Terminal, + exact: false + }, + { + href: `/dashboard/${server_id}/welcome-message`, + label: 'Welcome Message', + icon: MessageCircle, + exact: false + }, + { + href: `/dashboard/${server_id}/log-channel`, + label: 'Log Channel', + icon: FileText, + exact: false + }, + { + href: `/dashboard/${server_id}/tickets`, + label: 'Support Tickets', + icon: Ticket, + exact: false + }, + { + href: `/dashboard/${server_id}/reminders`, + label: 'Reminders', + icon: Bell, + exact: false + }, + { + href: '/dashboard/music', + label: 'Music Studio', + icon: Music2, + exact: false + }, + { + href: '/dashboard/broadcast', + label: 'Broadcaster', + icon: Send, + exact: false + }, + { + href: '/dashboard/integrations', + label: 'Twitch Streams', + icon: Layers, + exact: false + }, + { + href: '/dashboard/system', + label: 'Diagnostics', + icon: Activity, + exact: false + } + ]; + return ( -