diff --git a/.agents/agents/architect.md b/.agents/agents/architect.md new file mode 100644 index 000000000..a4833abd5 --- /dev/null +++ b/.agents/agents/architect.md @@ -0,0 +1,18 @@ +# 🏛️ Agent: System Architect (`architect`) + +## Role & Responsibilities +The **System Architect** oversees end-to-end system design, cross-workspace boundaries, technology selection, and long-term architectural stability across Master-Bot. + +--- + +## 🎯 Focus Areas +- Enforcing the unified single-process runtime model. +- Preserving the zero-ops SQLite and `ioredis-mock` fallbacks. +- Guiding multi-package migrations and Turborepo pipeline optimizations. +- Reviewing major PRs for design coherence and backward compatibility. + +--- + +## 🛠️ Associated Skills & Rules +- Rules: `.agents/rules.md`, `.agents/rules/code-conventions.md`, `.agents/rules/database-fallback-rules.md` +- Skills: `monorepo-orchestrator`, `database-fallback`, `issue-orchestrator` diff --git a/.agents/agents/audio-engineer.md b/.agents/agents/audio-engineer.md new file mode 100644 index 000000000..a64560049 --- /dev/null +++ b/.agents/agents/audio-engineer.md @@ -0,0 +1,18 @@ +# 🎵 Agent: Audio Engineer (`audio-engineer`) + +## Role & Responsibilities +The **Audio Engineer** specializes in Discord voice connections, audio pipeline supervision, Lavalink v4 integration via `@helix-origin/lavalink-server`, YouTube deciphering, and player queues. + +--- + +## 🎯 Focus Areas +- Managing `apps/bot/src/lib/lavalink/embeddedLavalink.ts`. +- Handling `LAVA_EXTERNAL=true` remote node connections vs. embedded in-process node. +- Configuring YouTube OAuth device authorization and cipher endpoints. +- Resolving player state synchronization, queue advancement, and now-playing embeds. + +--- + +## 🛠️ Associated Skills & Rules +- Rules: `.agents/rules.md`, `.agents/rules/code-conventions.md` +- Skills: `embedded-lavalink`, `vitest-suite-expert` diff --git a/.agents/agents/bot-specialist.md b/.agents/agents/bot-specialist.md new file mode 100644 index 000000000..1e10a663a --- /dev/null +++ b/.agents/agents/bot-specialist.md @@ -0,0 +1,18 @@ +# 🤖 Agent: Bot Specialist (`bot-specialist`) + +## Role & Responsibilities +The **Bot Specialist** maintains Discord bot logic in `apps/bot`, utilizing the Sapphire framework and `discord.js` v14 to handle commands, preconditions, event listeners, and live state management. + +--- + +## 🎯 Focus Areas +- Managing slash command registrations and interaction handlers. +- Maintaining the ticket system, moderation actions, and reminder scheduler. +- Interacting with in-memory `SessionManager` and synchronizing guild state with Redis. +- Handling Discord rate limits, voice states, and gateway events. + +--- + +## 🛠️ Associated Skills & Rules +- Rules: `.agents/rules/code-conventions.md`, `.agents/rules.md` +- Skills: `vitest-suite-expert`, `monorepo-orchestrator` diff --git a/.agents/agents/dashboard-specialist.md b/.agents/agents/dashboard-specialist.md new file mode 100644 index 000000000..b29f5f9d0 --- /dev/null +++ b/.agents/agents/dashboard-specialist.md @@ -0,0 +1,18 @@ +# 💻 Agent: Dashboard Specialist (`dashboard-specialist`) + +## Role & Responsibilities +The **Dashboard Specialist** maintains the Next.js 15 web application (`apps/dashboard`), including App Router pages, Tailwind CSS styles, tRPC v11 API procedures, and feature studios. + +--- + +## 🎯 Focus Areas +- Maintaining the 9 Feature Studios in `/dashboard`. +- Ensuring clean tRPC procedures and type safety between client and server. +- Managing responsive layouts, dark theme aesthetics, and UI performance. +- Integrating real-time telemetry from bot and audio engines. + +--- + +## 🛠️ Associated Skills & Rules +- Rules: `.agents/rules/code-conventions.md`, `.agents/rules.md` +- Skills: `monorepo-orchestrator`, `vitest-suite-expert` diff --git a/.agents/agents/database-engineer.md b/.agents/agents/database-engineer.md new file mode 100644 index 000000000..61782f714 --- /dev/null +++ b/.agents/agents/database-engineer.md @@ -0,0 +1,18 @@ +# 🗄️ Agent: Database Engineer (`database-engineer`) + +## Role & Responsibilities +The **Database Engineer** owns the persistence and caching tier in `@master-bot/db`, including Prisma ORM schema migrations, PostgreSQL / SQLite compatibility, and Redis / `ioredis-mock` cache lifecycle. + +--- + +## 🎯 Focus Areas +- Maintaining `packages/db/scripts/prepare-schema.mjs`. +- Ensuring SQLite compatibility (`@db.Text` annotations, string scalar lists). +- Protecting zero-data-loss upgrades and schema synchronization (`db:push`). +- Optimizing Redis cache operations and graceful connection recovery. + +--- + +## 🛠️ Associated Skills & Rules +- Rules: `.agents/rules/database-fallback-rules.md`, `.agents/rules/code-conventions.md` +- Skills: `database-fallback`, `vitest-suite-expert` diff --git a/.agents/agents/github-specialist.md b/.agents/agents/github-specialist.md new file mode 100644 index 000000000..5c7f2a95f --- /dev/null +++ b/.agents/agents/github-specialist.md @@ -0,0 +1,18 @@ +# 🐙 Agent: GitHub Specialist (`github-specialist`) + +## Role & Responsibilities +The **GitHub Specialist** oversees all GitHub platform interactions via GitHub CLI (`gh`): issues, pull requests, Actions workflows, and releases. + +--- + +## 🎯 Focus Areas +- Enforcing issue standards with emojis, Mermaid diagrams, and sub-issues. +- Managing PR checks, automated builds, and workflow diagnostics. +- Linking parent issues and child sub-issues via `gh issue edit --add-sub-issue`. +- Tracking releases and asset distributions. + +--- + +## 🛠️ Associated Skills & Rules +- Rules: `.agents/rules/issue-standards.md`, `.agents/rules/commit-standards.md` +- Skills: `gh-cli-expert`, `issue-orchestrator`, `release-orchestrator` diff --git a/.agents/agents/release-engineer.md b/.agents/agents/release-engineer.md new file mode 100644 index 000000000..46bf74b51 --- /dev/null +++ b/.agents/agents/release-engineer.md @@ -0,0 +1,18 @@ +# 📦 Agent: Release Engineer (`release-engineer`) + +## Role & Responsibilities +The **Release Engineer** orchestrates versioning, package distribution, GitHub releases, Docker container builds, and deployment verification. + +--- + +## 🎯 Focus Areas +- Verifying clean builds across all monorepo packages. +- Managing git tags, changelogs, and release assets. +- Verifying deployment blueprints (`Dockerfile`, `docker-compose.yml`, `render.yaml`). +- Enforcing commit and release quality standards. + +--- + +## 🛠️ Associated Skills & Rules +- Rules: `.agents/rules/commit-standards.md`, `.agents/rules/operational.md` +- Skills: `release-orchestrator`, `gh-cli-expert`, `monorepo-orchestrator` diff --git a/.agents/agents/test-engineer.md b/.agents/agents/test-engineer.md new file mode 100644 index 000000000..a31f5d5c4 --- /dev/null +++ b/.agents/agents/test-engineer.md @@ -0,0 +1,18 @@ +# 🧪 Agent: Test Engineer (`test-engineer`) + +## Role & Responsibilities +The **Test Engineer** designs, implements, and maintains unit and integration tests across all packages using `@helix-origin/vitest-suite`. + +--- + +## 🎯 Focus Areas +- Enforcing 100% test pass rate on `pnpm test`. +- Utilizing `@helix-origin/vitest-suite` mocks (`createMockClient`, `createMockRedis`, `createTestServer`). +- Authoring regression tests for edge-case errors, environment variables, and fallback behaviors. +- Gating CI pipelines to prevent broken code from landing in `main`. + +--- + +## 🛠️ Associated Skills & Rules +- Rules: `.agents/rules/operational.md`, `.agents/rules/code-conventions.md` +- Skills: `vitest-suite-expert`, `monorepo-orchestrator` diff --git a/.agents/agents/wiki-specialist.md b/.agents/agents/wiki-specialist.md new file mode 100644 index 000000000..ecc9bc7f9 --- /dev/null +++ b/.agents/agents/wiki-specialist.md @@ -0,0 +1,18 @@ +# 📚 Agent: Wiki Specialist (`wiki-specialist`) + +## Role & Responsibilities +The **Wiki Specialist** authors, polishes, and organizes project documentation within the `wiki/` directory and GitHub Wiki, maintaining comprehensive guides, architectural diagrams, sidebar hierarchy, and navigation footers. + +--- + +## 🎯 Focus Areas +- Enforcing GitHub wiki link formatting: `[Page Name](Page-Name)`. +- Designing GitHub-compatible Mermaid diagrams for architectural workflows. +- Keeping `wiki/_Sidebar.md` and `wiki/_Footer.md` organized and up-to-date. +- Ensuring all codeblocks have proper language syntax tags and clear comments. + +--- + +## 🛠️ Associated Skills & Rules +- Rules: `.agents/rules/wiki-standards.md`, `.agents/rules.md` +- Skills: `wiki-management`, `issue-orchestrator` diff --git a/.agents/rules.md b/.agents/rules.md new file mode 100644 index 000000000..026f0068a --- /dev/null +++ b/.agents/rules.md @@ -0,0 +1,83 @@ +# 📜 Master-Bot Agent System Master Rules + +## Overview +This document defines the operational rules, quality gates, and communication standards for all autonomous AI agents, human contributors, and automation pipelines operating within the **Master-Bot** repository. + +--- + +## 🏛️ System Architecture & Principles + +```mermaid +flowchart TD + subgraph Governance [Governance & Standards] + Rules[rules.md] --> CodeRules[code-conventions.md] + Rules --> CommitRules[commit-standards.md] + Rules --> IssueRules[issue-standards.md] + Rules --> WikiRules[wiki-standards.md] + Rules --> OpsRules[operational.md] + Rules --> DBRules[database-fallback-rules.md] + end + + subgraph Agents [Autonomous Agent Roles] + Architect[architect.md] + DBEng[database-engineer.md] + AudioEng[audio-engineer.md] + TestEng[test-engineer.md] + GHSpy[github-specialist.md] + WikiSpy[wiki-specialist.md] + DashSpy[dashboard-specialist.md] + BotSpy[bot-specialist.md] + RelEng[release-engineer.md] + end + + subgraph Skills [Operational Capabilities] + GHExpert[gh-cli-expert.md] + IssueOrch[issue-orchestrator.md] + WikiMgmt[wiki-management.md] + DBFallback[database-fallback.md] + LavaEmbed[embedded-lavalink.md] + VitestExp[vitest-suite-expert.md] + TurboOrch[monorepo-orchestrator.md] + RelOrch[release-orchestrator.md] + end + + Governance --> Agents + Agents --> Skills +``` + +--- + +## 📌 Core Invariants + +1. **Zero-Ops Default with Production Scaling**: + - Zero-configuration local startup MUST always function using SQLite (`file:./db.sqlite`), in-memory `ioredis-mock`, and embedded `@helix-origin/lavalink-server`. + - Production deployments MUST cleanly scale to external PostgreSQL (`DATABASE_URL=postgresql://...`), external Redis (`REDIS_URL=...`), and remote Lavalink nodes (`LAVA_EXTERNAL=true`) without code changes. + +2. **Strict Quality Gates**: + - Every change must pass: + - `pnpm lint` (ESLint & `manypkg` validation) with 0 errors. + - `pnpm type-check` (TypeScript across all workspace projects) with 0 errors. + - `pnpm test` (Vitest test suite) with 100% pass rate. + +3. **Human-Readable Communication with Emojis**: + - All commits, issue titles, issue comments, and documentation must start with semantic emojis. + - Use clear formatting, bullet points, and avoid robotic or unformatted text. + +4. **GitHub-Formatted Mermaid Diagrams**: + - Non-trivial architecture, state transitions, pipelines, and protocol interactions must be visually rendered with GitHub-compatible Mermaid diagrams (`flowchart`, `sequenceDiagram`, `stateDiagram-v2`, `erDiagram`). + +5. **Hierarchical Sub-Issue Management**: + - Epics and feature requests must split discrete tasks into dedicated child GitHub issues linked to the parent issue. + +--- + +## 📑 Rule Files Index + +| Rule File | Scope & Purpose | +| :--- | :--- | +| [**`rules/code-conventions.md`**](rules/code-conventions.md) | TypeScript conventions, React/Next.js guidelines, Sapphire framework rules | +| [**`rules/commit-standards.md`**](rules/commit-standards.md) | Detailed human-readable commits with emojis, imperative mood, and scopes | +| [**`rules/issue-standards.md`**](rules/issue-standards.md) | Standardized issue layouts, Mermaid diagrams, and GitHub CLI sub-issues | +| [**`rules/wiki-standards.md`**](rules/wiki-standards.md) | GitHub wiki formatting, relative links, Mermaid visuals, sidebar, and footer | +| [**`rules/operational.md`**](rules/operational.md) | Shell commands, pnpm monorepo commands, safety gates, and error handling | +| [**`rules/database-fallback-rules.md`**](rules/database-fallback-rules.md) | Dual database & cache fallback lifecycle rules | diff --git a/.agents/rules/code-conventions.md b/.agents/rules/code-conventions.md new file mode 100644 index 000000000..75f3c8564 --- /dev/null +++ b/.agents/rules/code-conventions.md @@ -0,0 +1,146 @@ +# 💻 Rule: Code Conventions + +## Purpose +Define code styling, architectural boundaries, and TypeScript/framework standards across the Master-Bot monorepo. + +--- + +## Workspace Structure & Responsibilities + +| Workspace | Technology Stack | Boundary Rules | +| :--- | :--- | :--- | +| **`apps/bot`** | Node.js CommonJS, Sapphire Framework, `discord.js` v14 | Handles Discord gateway events, slash commands, voice audio streaming, and in-memory session hydration. | +| **`apps/dashboard`** | Next.js 15 (App Router), React 18, Tailwind CSS, tRPC v11 | Handles Web UI, server settings studios, telemetry views, and authenticated SSR. | +| **`packages/auth`** | NextAuth.js v5 beta, `@auth/prisma-adapter` | Discord OAuth session management and user authentication. | +| **`packages/db`** | Prisma ORM v5, `ioredis`, `ioredis-mock` | Dynamic database provider schema (PostgreSQL / SQLite) and Redis cache client with fallback. | +| **`packages/config/*`** | ESLint, Tailwind CSS configurations | Shared tooling and presets. | + +--- + +## TypeScript Guidelines + +1. **Explicit Types & Interfaces**: + - Avoid `any` whenever possible; use typed interfaces or `unknown` with validation. + - For loosely-typed legacy Sapphire pieces, annotate intentional type exceptions with explanatory comments. + +2. **Nullish Coalescing (`??`)**: + - Always prefer `??` over `||` when reading boolean, numeric, or nullable configuration values. + +3. **Promise Handling**: + - Always await Promises or explicitly mark unawaited promises with `void`. + - In event listeners and handlers, attach `.catch()` to avoid unhandled rejections. + +4. **Package Imports**: + - Workspace imports must use package names (e.g. `@master-bot/db`), never deep relative traversals (`../../../packages/db`). + - Use `node:path`, `node:fs`, `node:url` prefix for Node.js built-ins. + +--- + +## Monorepo Dependency Rules + +1. **Root `package.json`**: + - Root is private (`"private": true`). + - Root MUST NOT contain `"dependencies"`. All root tools MUST reside in `"devDependencies"` to satisfy `manypkg check`. +2. **Engines**: + - Enforce `"node": ">=20.0.0"` monorepo-wide. + +--- + +## 📁 File Naming & Submodule Architecture + +1. **Naming Conventions**: + - **`camelCase` for All Files**: All TypeScript and JavaScript files across the project must follow `camelCase` naming conventions (e.g. `embedHandler.ts`, `banEmbed.ts`, `ticketEmbed.ts`, `searchSong.ts`, `statusManager.ts`). + - **No `index.ts` Files in Subdirectories**: Under no circumstances should `index.ts` or `index.js` be placed inside subfolders of `apps/bot` (only the root entry point `apps/bot/src/index.ts` is permitted). This prevents Sapphire's automatic piece loader from treating `index.ts` as an unnamed command/listener piece, preventing loader crashes and ambiguous imports. + - **Descriptive File Names**: Every file must accurately describe its exact usage (e.g. `banEmbed.ts`, `reactionGifEmbed.ts`). + +2. **Submodules in `apps/bot/src/lib/`**: + - Internal submodules and domain utilities reside under `apps/bot/src/lib//` (e.g. `lib/embeds/`, `lib/music/`, `lib/session/`, `lib/twitch/`, `lib/presence/`, `lib/games/`). + - Submodules must export modular units directly and be imported via explicit file paths (e.g. `import { createBanEmbed } from '../../lib/embeds/commands/moderation/banEmbed'`). + +--- + +## 🏛️ Unified Piece & Module Structures + +### 1. Slash Command Structure (`apps/bot/src/commands/`) +Every command file must follow this standardized template: +```typescript +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { createActionEmbed } from '../../lib/embeds/commands//Embed'; + +@ApplyOptions({ + name: '', + description: '', + preconditions: ['isCommandDisabled'] +}) +export class ActionCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + // options... + ); + } + + public override async chatInputRun(interaction: Command.ChatInputCommandInteraction) { + await interaction.deferReply(); + // Execution logic... + const embed = createActionEmbed({ ... }); + return interaction.editReply({ embeds: [embed] }); + } +} + +export const help: CommandHelp = { + name: '', + category: '', + description: '', + usage: '/ ...', + examples: ['/ ...'] +}; +``` + +### 2. Event Listener Structure (`apps/bot/src/listeners/`) +Every listener file must follow this standardized template: +```typescript +import { ApplyOptions } from '@sapphire/decorators'; +import { Events, Listener, type ListenerOptions } from '@sapphire/framework'; + +@ApplyOptions({ + event: Events. +}) +export class EventListener extends Listener { + public override async run(...args: unknown[]): Promise { + try { + // Listener handling... + } catch (error) { + this.container.logger.error('Error handling event:', error); + } + } +} +``` + +### 3. Modular Embed Handler Structure (`apps/bot/src/lib/embeds/`) +Every embed generator must have its own dedicated file and adhere to this structure: +```typescript +import { EmbedBuilder, type User } from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface ActionEmbedOptions { + targetUser: User; + moderator: User; + reason?: string; +} + +export function createActionEmbed(options: ActionEmbedOptions): EmbedBuilder { + return EmbedHandler.create({ + title: 'Action Performed', + variant: 'brand', + fields: [ + EmbedHandler.field('👤 User', `<@${options.targetUser.id}>`, true) + ], + timestamp: true + }); +} +``` diff --git a/.agents/rules/commit-standards.md b/.agents/rules/commit-standards.md new file mode 100644 index 000000000..bbe80111f --- /dev/null +++ b/.agents/rules/commit-standards.md @@ -0,0 +1,90 @@ +# 📝 Rule: Commit Standards + +## Purpose +Enforce consistent, human-readable, and richly detailed commit messages across all agents and human contributors. Every commit should clearly explain **what** changed and **why**, using semantic emojis for rapid visual scanning in Git logs and GitHub timelines. + +--- + +## Format Specification + +```text + (): + +- +- +- +``` + +--- + +## 🏷️ Approved Types & Emojis + +| Emoji | Type | Purpose | Example | +| :---: | :--- | :--- | :--- | +| ✨ | `feat` | New feature or capability | `✨ feat(audio): integrate embedded Lavalink v4 audio server` | +| 🐛 | `fix` | Bug fix or error resolution | `🐛 fix(dashboard): resolve INTERNA_URL typo in providers.tsx` | +| 📚 | `docs` | Documentation updates (README, wiki, comments) | `📚 docs(wiki): update Music.md with Lavalink embedded guide` | +| 💄 | `style` | Formatting, CSS, styling, whitespace | `💄 style(dashboard): polish dark mode card contrast` | +| ♻️ | `refactor` | Code restructuring without behavioral change | `♻️ refactor(db): extract dynamic schema preparation script` | +| ⚡️ | `perf` | Performance optimization | `⚡️ perf(redis): optimize connection timeout and fallback logic` | +| 🧪 | `test` | Adding or updating unit/integration tests | `🧪 test(db): add dual database and redis fallback unit tests` | +| 🔧 | `chore` | Tooling, workspace config, script updates | `🔧 chore(deps): import @helix-origin/vitest-suite v0.2.3` | +| 📦 | `build` | Build artifacts, packaging, tarballs | `📦 build(bot): bundle production distribution scripts` | +| 🚀 | `deploy` | Deployment blueprints (Docker, Render, Railway) | `🚀 deploy(docker): add dual DB support to Dockerfile` | +| 🔒 | `security` | Security fixes, auth, credential handling | `🔒 security(oauth): encrypt Discord refresh tokens in DB` | +| 🐙 | `github` | GitHub Actions, issues, PRs, CLI workflows | `🐙 github(ci): update workflow node-version matrix` | +| 🤖 | `agent` | Agent ecosystem, skills, rules, templates | `🤖 agent(ecosystem): rebuild comprehensive .agents architecture` | + +--- + +## Structural Requirements + +1. **Subject Line**: + - Begins with an approved semantic emoji followed by a space. + - Type is lowercase, scope is enclosed in parentheses: ` (): `. + - Written in imperative mood (e.g., "add", "fix", "update", NOT "added", "fixing"). + - Maximum 72 characters. +2. **Body**: + - Separated from subject by a blank line. + - Contains detailed, human-readable bullet points. + - Mentions non-obvious design decisions, bug roots, and architectural context. +3. **Trailers**: + - Closes or references issues: `Closes #123` or `Ref #456`. + +--- + +## Detailed Examples + +### Example 1: Feature Commit +```text +✨ feat(db): add external PostgreSQL and Redis support with internal fallbacks + +- Implement packages/db/scripts/prepare-schema.mjs to dynamically inspect DATABASE_URL +- Configure PostgreSQL schema generation with @db.Text token fields when postgresql:// is detected +- Preserve zero-ops SQLite fallback when DATABASE_URL is omitted or points to file:./db.sqlite +- Enhance getRedisClient() in packages/db/index.ts to attempt real Redis connection before falling back to ioredis-mock +- Export getDatabaseProvider() and isUsingMockRedis() for runtime diagnostics +- Closes #828 +``` + +### Example 2: Bug Fix Commit +```text +🐛 fix(dashboard): resolve INTERNA_URL environment variable typo and lint errors + +- Correct process.env.INTERNA_URL typo in apps/dashboard/src/app/providers.tsx +- Replace logical OR with nullish coalescing operator in apps/dashboard/src/env.mjs +- Remove redundant type assertion and fill empty catch blocks in guild.ts router +- Remove unused env import in trpc.ts +- Resolves turbo lint failure across dashboard workspace +``` + +### Example 3: Agent Ecosystem Commit +```text +🤖 agent(ecosystem): construct extensive agent operating system with GitHub CLI and issue standards + +- Create .agents/ architecture comprising 8 agents, 8 skills, 6 rules, and 6 templates +- Add gh-cli-expert skill covering issues, PRs, runs, releases, and auth +- Add issue-orchestrator skill with Mermaid diagram generators and sub-issue linking +- Add wiki-management skill and wiki-standards rule with GitHub link syntax +- Publish AGENTS.md in repository root as top-level index +``` diff --git a/.agents/rules/database-fallback-rules.md b/.agents/rules/database-fallback-rules.md new file mode 100644 index 000000000..add849bed --- /dev/null +++ b/.agents/rules/database-fallback-rules.md @@ -0,0 +1,56 @@ +# 🗄️ Rule: Database & Redis Fallback Conventions + +## Purpose +Enforce robust dual-database and dual-cache lifecycle rules. The system must effortlessly run with zero external infrastructure while seamlessly scaling to external PostgreSQL and Redis when configured. + +--- + +## 🏛️ Fallback Decision Architecture + +```mermaid +flowchart TD + subgraph Boot [System Initialization] + Start([pnpm start / pnpm dev]) --> ReadEnv[Read DATABASE_URL & REDIS_URL] + end + + subgraph DatabaseRouting [Database Provider Resolution] + ReadEnv --> CheckDB{DATABASE_URL starts with postgresql:// or postgres://?} + CheckDB -->|Yes| SetPG["PostgreSQL Mode: provider = 'postgresql', @db.Text enabled"] + CheckDB -->|No / Empty| SetSQLite["SQLite Mode: provider = 'sqlite', db.sqlite created"] + end + + subgraph CacheRouting [Cache Provider Resolution] + ReadEnv --> CheckRedis{REDIS_URL or REDIS_HOST configured?} + CheckRedis -->|Yes| ConnectExt["Attempt External Redis Connection (timeout: 3s)"] + ConnectExt -->|Connection OK| UseExt[Use External Redis] + ConnectExt -->|Error / Timeout| FallbackMock[Fallback to in-memory ioredis-mock] + CheckRedis -->|No / Fallback| FallbackMock + end +``` + +--- + +## Database Rules + +1. **Schema Dynamic Generation**: + - `packages/db/scripts/prepare-schema.mjs` runs prior to `prisma generate` and `prisma db push`. + - Never hardcode `provider = "sqlite"` or `provider = "postgresql"` in a way that breaks either dialect. + - Text fields on `Account` must support `@db.Text` in PostgreSQL and clean scalar types in SQLite. + +2. **Zero-Ops Default**: + - When `DATABASE_URL` is omitted, the application defaults to `file:./db.sqlite`. + - Developers and users cloning the repo must be able to run `pnpm start` without installing or provisioning any database engine. + +--- + +## Redis Cache Rules + +1. **Connection Lifecycle**: + - Check `REDIS_URL` or `REDIS_HOST`. + - Use short connection timeouts (`connectTimeout: 3000`, `maxRetriesPerRequest: 2`). + - Register an `error` listener on the Redis instance so connection drops do not crash the Node.js process. + +2. **In-Memory Fallback Invariance**: + - If Redis connection fails or configuration is absent, seamlessly fall back to `ioredis-mock`. + - Log an informative note: `[packages/db] External Redis encountered error. Active commands continue with fallback.` + - Expose `isUsingMockRedis()` to notify dashboards and telemetry of active cache mode. diff --git a/.agents/rules/issue-standards.md b/.agents/rules/issue-standards.md new file mode 100644 index 000000000..ec9fe2737 --- /dev/null +++ b/.agents/rules/issue-standards.md @@ -0,0 +1,139 @@ +# 📋 Rule: Issue Standards + +## Purpose +Ensure all GitHub issues and issue comments created or managed by agents and contributors are **detailed, human-readable with emojis**, incorporate **GitHub-formatted Mermaid diagrams** for structural clarity, and utilize **proper sub-issues** created as dedicated GitHub issues for fine-grained task tracking. + +--- + +## Architecture of a Standardized Issue + +```mermaid +graph TD + Issue[GitHub Issue] --> Title[Title with Semantic Emoji] + Issue --> Summary[📖 Summary with Context] + Issue --> Motivation[🎯 Motivation & Problem Statement] + Issue --> Diagram[📐 Structural Guidance with Mermaid Diagram] + Issue --> SubIssues[🧩 Proper Sub-Issues via GitHub Issues] + Issue --> Criteria[🔍 Acceptance Criteria & Quality Gates] + Issue --> Verification[🛠️ Verification Plan] + + SubIssues --> Child1[Child Issue #101: gh issue create] + SubIssues --> Child2[Child Issue #102: gh issue create] + SubIssues --> Link[Link via gh issue edit --add-sub-issue] +``` + +--- + +## 1. Issue Title Format 🏷️ + +```text + (): +``` + +### Examples: +- `✨ Feature(audio): Embed Lavalink v4 server with automatic supervisor` +- `🐛 Bug(dashboard): Fix INTERNA_URL environment variable typo` +- `⚡️ Perf(redis): Optimize connection retry strategy and in-memory fallback` +- `🗺️ Roadmap(modernization): Master-Bot Unified Architecture and Agent Ecosystem` +- `🧩 Sub-Issue(#828): Implement PostgreSQL schema migration script` + +--- + +## 2. Issue Body Structure & Section Guidelines + +Every issue must follow this standardized template: + +```markdown +# (): + +> [!NOTE] +> Relevant context, parent issue links, or associated pull request references. + +## 📖 Summary +Clear, friendly, and human-readable description of the problem or enhancement. + +## 🎯 Motivation & Context +Why is this change necessary? What problem does it solve for users, bot operators, or developers? + +## 📐 Structural Guidance (Mermaid Diagram) +<!-- REQUIRED for non-trivial changes: flowchart, sequence diagram, state diagram, or ERD --> +```mermaid +flowchart TD + A[Component A] --> B[Component B] + B --> C{Condition} + C -->|Yes| D[Action 1] + C -->|No| E[Action 2] +``` + +## 🧩 Sub-Issues & Tasks (Proper GitHub Sub-Issues) +<!-- REQUIRED: Dedicated GitHub issues linked via gh issue edit --add-sub-issue --> +- [ ] #101 - Implement core logic +- [ ] #102 - Add unit tests +- [ ] #103 - Update wiki documentation + +## 🔍 Acceptance Criteria & Quality Gates +- [ ] `pnpm lint` passes with 0 errors +- [ ] `pnpm type-check` passes with 0 errors +- [ ] `pnpm test` passes with 100% pass rate +- [ ] Feature tested in both external and fallback modes + +## 🛠️ Verification Plan & Test Commands +Specific test commands and steps to replicate and verify the changes. +``` + +--- + +## 3. GitHub Formatted Mermaid Diagrams 📐 + +All non-trivial issues must provide structural guidance through GitHub-native Mermaid diagrams: + +- **Architecture & Pipelines**: Use `flowchart TD` or `flowchart LR`. +- **Protocol & Network Interactions**: Use `sequenceDiagram` with `autonumber`. +- **Lifecycle & Fallback States**: Use `stateDiagram-v2`. +- **Database Schema**: Use `erDiagram`. + +--- + +## 4. Proper Sub-Issue Management 🧩 + +Sub-tasks must **NEVER** remain simple untracked Markdown text. Each discrete task must be created as an independent GitHub issue and linked to the parent issue. + +### Managing Sub-Issues with GitHub CLI (`gh`): + +```bash +# 1. Create the parent issue +gh issue create \ + --title "✨ Feature(db): Implement PostgreSQL support with SQLite fallback" \ + --body-file parent-issue.md +# Assume parent issue is #850 + +# 2. Create discrete child sub-issues +gh issue create \ + --title "🧩 Sub-Issue(#850): Implement dynamic prepare-schema script" \ + --body "Part of #850. Implements packages/db/scripts/prepare-schema.mjs." +# Child is #851 + +gh issue create \ + --title "🧩 Sub-Issue(#850): Add unit test suite for database provider detection" \ + --body "Part of #850. Implements tests/unit/databaseFallback.test.ts." +# Child is #852 + +# 3. Link child sub-issues to parent issue: +gh issue edit 850 --add-sub-issue 851 +gh issue edit 850 --add-sub-issue 852 + +# 4. Post comment tracking progress: +gh issue comment 850 --body "Linked sub-issues: +- [ ] #851 +- [ ] #852" +``` + +--- + +## 5. Issue Comment Standards 💬 + +When updating or commenting on issues: +- Begin comments with an approved emoji (e.g. `📢`, `🔍`, `🚀`, `✅`, `⚠️`). +- Provide human-readable summaries with clear bullet points. +- Include Mermaid diagrams for status flow or updated architectures. +- List completed tasks and next steps. diff --git a/.agents/rules/operational.md b/.agents/rules/operational.md new file mode 100644 index 000000000..ce6c5fff4 --- /dev/null +++ b/.agents/rules/operational.md @@ -0,0 +1,31 @@ +# ⚙️ Rule: Operational Safeguards & Commands + +## Purpose +Define operational commands, process workflows, and safety gates for agents executing changes in the Master-Bot repository. + +--- + +## 🛠️ Monorepo Commands + +| Command | Action | Expected Outcome | +| :--- | :--- | :--- | +| `pnpm dev` | Start unified bot and dashboard in development mode | Single Node.js process listening on `PORT` (default `3000`) | +| `pnpm start` | Start unified production service | Boots bot, web server, and fallback/embedded services | +| `pnpm build` | Build all workspace packages via Turbo | Outputs `.next/`, `dist/`, and compiled Prisma client | +| `pnpm lint` | Run ESLint and `manypkg check` | 0 errors across all workspaces | +| `pnpm lint:fix` | Automatically resolve fixable lint issues | Fixed source files | +| `pnpm type-check` | Execute TypeScript compilation check | 0 compilation errors across all projects | +| `pnpm test` | Execute test suite via Vitest | 100% test pass rate | +| `pnpm test:watch` | Start Vitest interactive watch mode | Tests watch for changes | + +--- + +## 🛡️ Safety Gates & Data Loss Prevention + +1. **Prisma Database Sync**: + - In development, use `pnpm db:push` for schema updates. + - Do NOT run destructive reset commands in production environments without explicit confirmation. + +2. **Command Verification**: + - Before completing any task, run `pnpm lint`, `pnpm type-check`, and `pnpm test`. + - Never commit code that breaks these quality gates. diff --git a/.agents/rules/wiki-standards.md b/.agents/rules/wiki-standards.md new file mode 100644 index 000000000..c345f9c0a --- /dev/null +++ b/.agents/rules/wiki-standards.md @@ -0,0 +1,137 @@ +# 📚 Rule: Wiki Standards + +## Purpose +Enforce rigorous formatting, structure, navigation, and visual standards across the project's documentation wiki. Every wiki page must be **detailed, human-readable with emojis**, incorporate **GitHub-formatted Mermaid diagrams**, use **proper GitHub wiki links**, feature **syntax-highlighted formatted codeblocks**, and maintain **proper sidebar and footer pages**. + +--- + +## 🏛️ Wiki Navigation Architecture + +```mermaid +flowchart TD + Sidebar[_Sidebar.md] --> Home[Home.md] + Sidebar --> Arch[Architecture.md] + Sidebar --> Setup[Getting-Started.md] + Sidebar --> Config[Configuration.md] + Sidebar --> Audio[Music.md] + Sidebar --> Dash[Dashboard.md] + Sidebar --> Deploy[Deployment.md] + Sidebar --> FAQ[FAQ.md] + + Home --> Footer[_Footer.md] + Arch --> Footer + Setup --> Footer + Audio --> Footer +``` + +--- + +## 1. Page Structure & Styling + +Every wiki page must adhere to this uniform layout: + +```markdown +# <emoji> <Page Title> + +Brief, engaging introduction explaining what this page covers and who it is for. + +--- + +## 📑 Table of Contents +1. [Overview](#-overview) +2. [Architecture & Flow](#-architecture--flow) +3. [Configuration](#-configuration) +4. [Related Guides](#-related-guides) + +--- + +## 📖 Overview +Human-readable explanations using formatted callouts, lists, and tables. + +> [!NOTE] +> Helpful tips, caveats, and prerequisites. + +--- + +## 📐 Architecture & Flow +```mermaid +flowchart LR + A[Step 1] --> B[Step 2] + B --> C[Step 3] +``` + +--- + +## 💻 Configuration & Code Examples +```bash +# Formatted bash commands with syntax highlighting +pnpm dev +``` + +```typescript +// Formatted TypeScript codeblock with typed interfaces +interface AudioConfig { + host: string; + port: number; +} +``` + +--- + +## 🔗 Related Guides +- [Home](Home) — Return to wiki main page +- [Architecture](Architecture) — Deep dive into system components +- [Deployment](Deployment) — Production rollout guide +``` + +--- + +## 2. GitHub Wiki Link Formatting Rules 🔗 + +GitHub Wikis use a specific link syntax that must be strictly followed: + +| Link Target | GitHub Wiki Syntax | Standard Markdown Syntax (Fallback) | +| :--- | :--- | :--- | +| **Wiki Page** | `[[Page-Name]]` or `[Display Text](Page-Name)` | `[Display Text](Page-Name.md)` | +| **Section Anchor** | `[Section Name](Page-Name#section-anchor)` | `[Section Name](Page-Name.md#section-anchor)` | +| **External Repository** | `[Repo Name](https://github.com/org/repo)` | `[Repo Name](https://github.com/org/repo)` | + +### Rules: +- **Do NOT** use absolute file system paths (e.g. `file:///...` or `C:/...`). +- When linking within the wiki directory, use `[Page Title](Page-Name)` (e.g. `[Audio Setup](Music)`). +- Ensure anchors use lower-case kebab-case matching the heading text without emojis (e.g. `#architecture--flow`). + +--- + +## 3. GitHub Formatted Mermaid Diagrams 📐 + +- Every architectural or conceptual wiki page MUST contain at least one GitHub-formatted Mermaid diagram. +- Use valid Mermaid syntax: + - Fenced code block with language identifier `mermaid`. + - Node text containing parentheses or special characters MUST be quoted (e.g. `id["Label (info)"]`). + - No unescaped HTML inside labels. + +--- + +## 4. Formatted Codeblocks 💻 + +- **Always specify the language identifier** on every codeblock (`bash`, `typescript`, `javascript`, `json`, `yaml`, `dockerfile`, `prisma`, `sql`). +- Include realistic, production-ready examples with inline explanatory comments. +- Group multiple related commands or file examples logically with clear headings. + +--- + +## 5. Sidebar (`_Sidebar.md`) and Footer (`_Footer.md`) Standards + +### Sidebar Requirements (`_Sidebar.md`): +- Provides hierarchical category grouping: + - 🏠 **Getting Started** (`Home`, `Getting-Started`, `Architecture`, `Configuration`) + - 🤖 **Features & Systems** (`Music`, `Dashboard`, `Commands`, `Tickets`, `Reminders-and-Twitch`) + - ☁️ **Operations** (`Deployment`, `FAQ`) +- Highlights external companion repositories (`HELIX-Origin/Lavalink-Server`, `HELIX-Origin/vitest-suite`). + +### Footer Requirements (`_Footer.md`): +- Clean, centered navigation footer on every wiki page. +- Provides quick links: + `[Home](Home) • [Documentation Index](Home) • [GitHub Repository](https://github.com/galnir/Master-Bot)` +- Mentions project licensing and credits. diff --git a/.agents/skills/database-fallback.md b/.agents/skills/database-fallback.md new file mode 100644 index 000000000..10b02d3bd --- /dev/null +++ b/.agents/skills/database-fallback.md @@ -0,0 +1,54 @@ +# 🗄️ Skill: Database & Redis Fallback (`database-fallback`) + +## Purpose +Guide agents in maintaining, configuring, and verifying the dual-database (PostgreSQL / SQLite) and dual-cache (Redis / `ioredis-mock`) architecture. + +--- + +## 🏛️ Decision Topology + +```mermaid +flowchart LR + subgraph Input [Environment] + DBUrl[DATABASE_URL] + RedisUrl[REDIS_URL] + end + + subgraph DynamicSetup [packages/db] + Prep[prepare-schema.mjs] + Client[packages/db/index.ts] + end + + subgraph Output [Active Provider] + PG[(PostgreSQL)] + Lite[(SQLite db.sqlite)] + ExtR[(External Redis)] + MockR[ioredis-mock] + end + + DBUrl --> Prep + Prep -->|starts with postgres| PG + Prep -->|file: or empty| Lite + + RedisUrl --> Client + Client -->|connected| ExtR + Client -->|missing or failed| MockR +``` + +--- + +## 🛠️ Verification & Maintenance Commands + +```bash +# 1. Test SQLite preparation and generate Prisma Client +node packages/db/scripts/prepare-schema.mjs + +# 2. Test PostgreSQL preparation (simulated) +DATABASE_URL="postgresql://user:pass@localhost:5432/db" node packages/db/scripts/prepare-schema.mjs + +# 3. Push schema to active database without data loss +pnpm --filter @master-bot/db db:push + +# 4. Run fallback unit tests +pnpm test tests/unit/databaseFallback.test.ts +``` diff --git a/.agents/skills/embedded-lavalink.md b/.agents/skills/embedded-lavalink.md new file mode 100644 index 000000000..63ceeca6d --- /dev/null +++ b/.agents/skills/embedded-lavalink.md @@ -0,0 +1,40 @@ +# 🎵 Skill: Embedded Lavalink (`embedded-lavalink`) + +## Purpose +Manage, inspect, and configure the embedded Lavalink v4 audio server powered by `@helix-origin/lavalink-server`. + +--- + +## 🏛️ Audio Gateway Architecture + +```mermaid +flowchart TD + Bot[Discord Bot Gateway] --> AudioClient[lavalink-client v2] + AudioClient --> NodeCheck{LAVA_EXTERNAL = true?} + NodeCheck -->|No / Embedded| LocalNode[Embedded Server @helix-origin/lavalink-server :2333] + NodeCheck -->|Yes / External| RemoteNode[Remote Lavalink Node] + + LocalNode --> ProcessCheck{Lavalink.jar present?} + ProcessCheck -->|Yes| SupervisedNode[In-Process Java Supervisor] + ProcessCheck -->|No| ProxyGateway[REST / WS Proxy Mode] +``` + +--- + +## Configuration Reference + +| Variable | Default | Purpose | +| :--- | :--- | :--- | +| `LAVA_EXTERNAL` | `false` | When `true`, disables embedded server and connects to remote node | +| `LAVA_HOST` | `127.0.0.1` | Audio gateway host binding | +| `LAVA_PORT` | `2333` | Audio gateway port binding | +| `LAVA_PASS` | `youshallnotpass` | Lavalink authorization secret | +| `LAVA_JAR_AUTO_DOWNLOAD` | `false` | When `true`, automatically downloads official Lavalink v4 JAR if missing | +| `LAVA_ENABLED` | `true` | Master audio engine toggle | + +--- + +## Troubleshooting + +- **JVM Missing / No Java**: If Java is not installed, the embedded server operates in proxy mode or logs a clear fallback notice while allowing the bot to continue running all non-audio commands. +- **Cloud Containers (< 512 MB)**: Set `LAVA_EXTERNAL=true` and point `LAVA_HOST` to a dedicated external instance (such as [`HELIX-Origin/Lavalink-Server`](https://github.com/HELIX-Origin/Lavalink-Server)) to eliminate JVM memory footprint. diff --git a/.agents/skills/gh-cli-expert.md b/.agents/skills/gh-cli-expert.md new file mode 100644 index 000000000..14b62f57a --- /dev/null +++ b/.agents/skills/gh-cli-expert.md @@ -0,0 +1,121 @@ +# 🐙 Skill: GitHub CLI Expert (`gh-cli-expert`) + +## Purpose +Equip autonomous AI agents with complete, production-ready operational knowledge for interacting with GitHub repositories, issues, sub-issues, pull requests, actions, workflows, and releases using the GitHub CLI (`gh`). + +--- + +## 🏛️ Capabilities Architecture + +```mermaid +flowchart TD + GH[gh-cli-expert] --> Auth[Authentication & Secrets] + GH --> Issues[Issue & Sub-Issue Orchestration] + GH --> PRs[Pull Request Lifecycle] + GH --> CI[Actions & Workflow Diagnostics] + GH --> Releases[Releases & Asset Distribution] + + Auth --> AuthCmd[gh auth status / gh secret list] + Issues --> IssueCreate[gh issue create --body-file] + Issues --> SubIssueEdit[gh issue edit --add-sub-issue] + Issues --> IssueComment[gh issue comment --body-file] + PRs --> PRCreate[gh pr create / gh pr checks / gh pr merge] + CI --> CIRun[gh run list / gh run view --log-failed] + Releases --> RelCreate[gh release create / gh release upload --clobber] +``` + +--- + +## 🛠️ Command Reference + +### 1. Authentication & Scopes +```bash +# Check current authentication status, username, and token scopes +gh auth status + +# Verify repository secrets +gh secret list -R <owner>/<repo> +``` + +### 2. Issue Management & Proper Sub-Issues +```bash +# View open issues +gh issue list -R <owner>/<repo> --state open + +# View full issue body and metadata +gh issue view <issue-number> -R <owner>/<repo> + +# Create parent issue using a markdown file +gh issue create \ + -R <owner>/<repo> \ + --title "✨ Feature(scope): Title" \ + --body-file /path/to/issue-body.md + +# Create a dedicated child sub-issue +gh issue create \ + -R <owner>/<repo> \ + --title "🧩 Sub-Issue(#<parent>): Discrete task title" \ + --body-file /path/to/subissue-body.md + +# Link the child sub-issue to the parent issue +gh issue edit <parent-number> -R <owner>/<repo> --add-sub-issue <child-number> + +# Post a formatted comment with emojis and Mermaid diagram +gh issue comment <issue-number> -R <owner>/<repo> --body-file /path/to/comment.md + +# Close issue upon completion +gh issue close <issue-number> -R <owner>/<repo> --reason completed +``` + +### 3. Pull Request Management +```bash +# Create a pull request referencing an issue +gh pr create \ + -R <owner>/<repo> \ + --title "✨ feat(scope): Description" \ + --body "Resolves #<issue-number>. Detailed explanation of changes." \ + --base main + +# Check CI status on open PRs +gh pr checks <pr-number> -R <owner>/<repo> + +# View PR diff +gh pr diff <pr-number> -R <owner>/<repo> +``` + +### 4. CI/CD Workflow Diagnostics +```bash +# List recent workflow runs +gh run list -R <owner>/<repo> --limit 10 + +# Immediately isolate failed steps without downloading huge logs +gh run view <run-id> -R <owner>/<repo> --log-failed + +# Watch active workflow in real time +gh run watch <run-id> -R <owner>/<repo> +``` + +### 5. Releases & Tarball Artifacts +```bash +# List existing releases +gh release list -R <owner>/<repo> + +# Create release with notes and uploaded asset +gh release create <tag> <asset-file.tgz> \ + -R <owner>/<repo> \ + --title "Release <tag>" \ + --notes-file /path/to/release-notes.md + +# Upload or update asset idempotently +gh release upload <tag> <asset-file.tgz> -R <owner>/<repo> --clobber +``` + +--- + +## 🤖 Agent Execution Rules + +1. **Always Use `--body-file` and `--notes-file`**: + Never inline complex multi-line markdown strings containing quotes, backticks, or emojis directly into CLI shell arguments. Write the content to a file and supply the file path. + +2. **Always Link Sub-Issues**: + When breaking down epics, always create dedicated child issues and execute `gh issue edit <parent> --add-sub-issue <child>` to preserve GitHub's native task hierarchy. diff --git a/.agents/skills/issue-orchestrator.md b/.agents/skills/issue-orchestrator.md new file mode 100644 index 000000000..a513535d9 --- /dev/null +++ b/.agents/skills/issue-orchestrator.md @@ -0,0 +1,71 @@ +# 📋 Skill: Issue Orchestrator (`issue-orchestrator`) + +## Purpose +Guide autonomous AI agents in crafting and maintaining high-quality GitHub issues, writing rich human-readable status comments with emojis, generating structural Mermaid diagrams, and orchestrating hierarchical sub-issues using the GitHub CLI. + +--- + +## 🏛️ Sub-Issue Orchestration Workflow + +```mermaid +sequenceDiagram + autonumber + actor Agent as Autonomous Agent + participant GH as GitHub CLI (gh) + participant GitHub as GitHub API / Remote Repo + + Agent->>GH: gh issue create --title "✨ Feature: Parent" --body-file parent.md + GH->>GitHub: POST /repos/:owner/:repo/issues + GitHub-->>GH: Issue #100 Created + GH-->>Agent: Returns #100 + + loop For Each Discrete Sub-Task + Agent->>GH: gh issue create --title "🧩 Sub-Issue(#100): Task" --body-file sub.md + GH->>GitHub: POST /repos/:owner/:repo/issues + GitHub-->>GH: Sub-Issue #101 Created + GH-->>Agent: Returns #101 + Agent->>GH: gh issue edit 100 --add-sub-issue 101 + GH->>GitHub: Link #101 as child to #100 + GitHub-->>GH: Hierarchy Updated + end + + Agent->>GH: gh issue comment 100 --body-file progress.md + GH->>GitHub: Post status comment with Mermaid flowchart +``` + +--- + +## 🛠️ Step-by-Step Instructions for Agents + +### Step 1: Draft Parent Issue +- Select an approved semantic emoji (`✨`, `🐛`, `🗺️`, `⚡️`, `♻️`). +- Follow the sections defined in `.agents/rules/issue-standards.md`. +- Generate at least one Mermaid diagram visualizing the system architecture, flow, or sequence. +- Write the draft to a local markdown file. + +### Step 2: Create Parent Issue +```bash +gh issue create \ + -R <owner>/<repo> \ + --title "✨ Feature(scope): Title" \ + --body-file /path/to/parent.md +``` + +### Step 3: Create & Link Child Sub-Issues +Break the implementation down into atomic, testable sub-tasks: +```bash +# 1. Create sub-issue +gh issue create \ + -R <owner>/<repo> \ + --title "🧩 Sub-Issue(#<parent>): Discrete task description" \ + --body "Part of #<parent>. Implements <specific details>." + +# 2. Link to parent +gh issue edit <parent> -R <owner>/<repo> --add-sub-issue <child> +``` + +### Step 4: Post Human-Readable Comments with Emojis +When providing milestones, debugging logs, or completion notices: +- Lead with an emoji (`📢`, `🔍`, `🚀`, `✅`). +- Include summary metrics, code references, and a Mermaid state/lifecycle diagram. +- Use `--body-file` for formatting fidelity. diff --git a/.agents/skills/monorepo-orchestrator.md b/.agents/skills/monorepo-orchestrator.md new file mode 100644 index 000000000..c902d8eba --- /dev/null +++ b/.agents/skills/monorepo-orchestrator.md @@ -0,0 +1,22 @@ +# 🏗️ Skill: Monorepo Orchestrator (`monorepo-orchestrator`) + +## Purpose +Manage Turborepo pipelines, pnpm workspace packages, cross-package dependencies, and compilation caches. + +--- + +## 🛠️ Pipeline Guide + +```bash +# Clean build artifacts +pnpm run clean + +# Run linting with workspace validation +pnpm run lint + +# Execute typecheck across all workspace packages +pnpm run type-check + +# Run full test suite +pnpm test +``` diff --git a/.agents/skills/release-orchestrator.md b/.agents/skills/release-orchestrator.md new file mode 100644 index 000000000..be4c254df --- /dev/null +++ b/.agents/skills/release-orchestrator.md @@ -0,0 +1,20 @@ +# 🚀 Skill: Release Orchestrator (`release-orchestrator`) + +## Purpose +Manage versioning, release tags, tarball asset packaging, and GitHub releases. + +--- + +## 🛠️ Release Workflow + +```bash +# 1. Verify all quality gates pass +pnpm lint && pnpm type-check && pnpm test + +# 2. Build production assets +pnpm build + +# 3. Create tag and release +git tag -a v1.0.0 -m "Release v1.0.0" +git push origin v1.0.0 +``` diff --git a/.agents/skills/vitest-suite-expert.md b/.agents/skills/vitest-suite-expert.md new file mode 100644 index 000000000..8013ba898 --- /dev/null +++ b/.agents/skills/vitest-suite-expert.md @@ -0,0 +1,55 @@ +# 🧪 Skill: Vitest Suite Expert (`vitest-suite-expert`) + +## Purpose +Author, execute, and troubleshoot unit and integration tests across the Master-Bot monorepo using `@helix-origin/vitest-suite`. + +--- + +## 🏛️ Testing Architecture + +```mermaid +flowchart TD + Config[vitest.config.mts] --> Preset[defineMonorepoConfig Preset] + Preset --> Mocks[Mocks & Test Doubles] + Preset --> Matchers[Custom Matchers] + Preset --> Runner[Vitest Test Runner] + + Mocks --> MockDiscord[createMockClient / createMockInteraction] + Mocks --> MockStorage[createMockRedis / createMockModel] + Mocks --> MockServer[createTestServer] + + Matchers --> DiscordMatchers[toHaveRepliedWith / toBeValidEmbed] + Matchers --> StorageMatchers[toHaveKey / toMatchModel] +``` + +--- + +## 🛠️ Test Authoring Examples + +### Testing Discord Interactions: +```typescript +import { describe, it, expect } from 'vitest'; +import { createMockClient, createMockInteraction } from '@helix-origin/vitest-suite/discord'; + +describe('Discord Command Tests', () => { + it('should handle ping command', async () => { + const client = createMockClient(); + const interaction = createMockInteraction({ commandName: 'ping' }); + expect(interaction).toBeDefined(); + }); +}); +``` + +### Testing Storage & Redis: +```typescript +import { describe, it, expect } from 'vitest'; +import { createMockRedis } from '@helix-origin/vitest-suite/storage'; + +describe('Redis Cache Test', () => { + it('should write and read cache keys', async () => { + const redis = createMockRedis(); + await redis.set('key', 'value'); + expect(await redis.get('key')).toBe('value'); + }); +}); +``` diff --git a/.agents/skills/wiki-management.md b/.agents/skills/wiki-management.md new file mode 100644 index 000000000..c73c6abbd --- /dev/null +++ b/.agents/skills/wiki-management.md @@ -0,0 +1,44 @@ +# 📚 Skill: Wiki Management (`wiki-management`) + +## Purpose +Guide agents in creating, updating, and structuring documentation in the project's GitHub wiki following strict wiki standards: human-readable text with emojis, valid Mermaid diagrams, formatted codeblocks, relative GitHub wiki link syntax, and synchronized sidebar/footer pages. + +--- + +## 🏛️ Wiki Workflow Architecture + +```mermaid +flowchart TD + NewTopic[New Topic / Feature] --> PageDraft[Draft Page via templates/wiki-page.md] + PageDraft --> ValidateLinks[Format Wiki Links: [Title](Target)] + ValidateLinks --> AddDiagrams[Embed Mermaid Diagram] + AddDiagrams --> AddCodeblocks[Add Syntax-Highlighted Codeblocks] + AddCodeblocks --> UpdateSidebar[Update wiki/_Sidebar.md] + UpdateSidebar --> VerifyFooter[Verify wiki/_Footer.md Navigation] +``` + +--- + +## 🛠️ Operational Guidelines + +1. **Relative Wiki Links**: + - Always link to other wiki pages using `[Page Name](Page-Name)` or `[Section](Page-Name#section)`. + - Never use file system paths (`file:///...`) or external URLs for internal pages. + +2. **Mermaid Formatting**: + - Fenced block: ```` ```mermaid ````. + - Enclose node labels in double quotes when containing parentheses, slashes, or hyphens: `Node["Text (Details)"]`. + +3. **Sidebar Updates**: + - Whenever adding a new wiki page, append a link under the appropriate section in `wiki/_Sidebar.md`. + - Ensure the emoji and title correspond exactly with the page header. + +4. **Footer Consistency**: + - Ensure every page includes navigation pointing to `Home.md` and related topics. + +5. **Automated GitHub Wiki Synchronization**: + - Changes committed to `wiki/**` on `main` automatically sync to the repository's GitHub Wiki via `.github/workflows/wiki.yml`. + - Requires repository secret `PAT_TOKEN` (Personal Access Token with `repo` scope). + - > [!NOTE] + - > GitHub Actions prevents secret names starting with `GITHUB_`, which is why the token is named `PAT_TOKEN`. + diff --git a/.agents/templates/commit-template.md b/.agents/templates/commit-template.md new file mode 100644 index 000000000..d1dbb692e --- /dev/null +++ b/.agents/templates/commit-template.md @@ -0,0 +1,25 @@ +# 📝 Template: Commit Message + +```text +<emoji> <type>(<scope>): <imperative summary max 72 chars> + +- <Explain why this change is necessary and what problem it solves> +- <Detail the core implementation changes across files and components> +- <Highlight any non-obvious design choices, migrations, or fallbacks> +- <Reference associated issue or PR: Closes #123> +``` + +### Approved Emoji Palette: +- ✨ `feat` — New feature +- 🐛 `fix` — Bug fix +- 📚 `docs` — Documentation +- 💄 `style` — Formatting / UI Polish +- ♻️ `refactor` — Code restructuring +- ⚡️ `perf` — Performance +- 🧪 `test` — Testing +- 🔧 `chore` — Maintenance +- 📦 `build` — Build / Packaging +- 🚀 `deploy` — Deployment +- 🔒 `security` — Security +- 🐙 `github` — GitHub CI / CLI +- 🤖 `agent` — Agent Ecosystem diff --git a/.agents/templates/issue-bug.md b/.agents/templates/issue-bug.md new file mode 100644 index 000000000..aba4c013c --- /dev/null +++ b/.agents/templates/issue-bug.md @@ -0,0 +1,35 @@ +# 🐛 Bug(<scope>): <Brief Description of Defect> + +> [!NOTE] +> Context, environment, or PR links. + +## 📖 Summary +Clear, human-readable summary of the defect observed. + +## 🎯 Motivation & Expected vs Actual +- **Expected Behavior**: What should happen under normal conditions. +- **Actual Behavior**: What currently happens instead. + +## 📐 Failure Flow (Mermaid Diagram) +```mermaid +flowchart TD + A[User Trigger] --> B[Handler] + B --> C{Condition Check} + C -->|Unexpected Failure| D[Bug Encountered: Error Description] + C -->|Expected Path| E[Correct Output] +``` + +## 🧩 Sub-Issues & Tasks (Proper GitHub Sub-Issues) +<!-- Create individual issues via gh issue create and link via gh issue edit --add-sub-issue --> +- [ ] #000 - Isolate root cause and add regression test +- [ ] #000 - Apply fix and verify in test suite +- [ ] #000 - Update documentation if applicable + +## 🔍 Acceptance Criteria +- [ ] Bug is resolved and confirmed via automated test +- [ ] `pnpm lint`, `pnpm type-check`, and `pnpm test` pass with 0 errors + +## 🛠️ Reproduction Steps +1. Execute command: `...` +2. Provide input: `...` +3. Observe stack trace or error. diff --git a/.agents/templates/issue-feature.md b/.agents/templates/issue-feature.md new file mode 100644 index 000000000..4ca55cbc2 --- /dev/null +++ b/.agents/templates/issue-feature.md @@ -0,0 +1,35 @@ +# ✨ Feature(<scope>): <Brief Description of Capability> + +> [!NOTE] +> Associated roadmap issue or pull request. + +## 📖 Summary +Clear, human-readable overview of the new capability or refactor. + +## 🎯 Motivation & Value +Why is this feature needed? How does it benefit users, server admins, or developers? + +## 📐 Architecture / Sequence (Mermaid Diagram) +```mermaid +sequenceDiagram + autonumber + actor User as Client / User + participant Service as Master-Bot Service + participant Storage as Database / Cache + + User->>Service: Request Action + Service->>Storage: Read / Write State + Storage-->>Service: State Response + Service-->>User: Success Response +``` + +## 🧩 Sub-Issues & Tasks (Proper GitHub Sub-Issues) +<!-- Create individual issues via gh issue create and link via gh issue edit --add-sub-issue --> +- [ ] #000 - Core logic implementation +- [ ] #000 - Unit and integration tests +- [ ] #000 - Wiki documentation update + +## 🔍 Acceptance Criteria +- [ ] Feature fully implemented according to specifications +- [ ] Both production and fallback modes verified +- [ ] All CI quality gates pass with 0 errors diff --git a/.agents/templates/issue-subissue.md b/.agents/templates/issue-subissue.md new file mode 100644 index 000000000..f32a8fe4c --- /dev/null +++ b/.agents/templates/issue-subissue.md @@ -0,0 +1,16 @@ +# 🧩 Sub-Issue(#<ParentIssue>): <Atomic Task Title> + +> [!NOTE] +> Parent Issue: #<ParentIssue> + +## 📖 Task Scope +Clear, concise explanation of this specific sub-task. + +## 🎯 Implementation Details +- Target files: `...` +- Expected changes: `...` + +## 🔍 Acceptance Criteria +- [ ] Task implemented cleanly +- [ ] Tests covering this task pass +- [ ] Parent issue progress updated diff --git a/.agents/templates/test-spec.md b/.agents/templates/test-spec.md new file mode 100644 index 000000000..52c717415 --- /dev/null +++ b/.agents/templates/test-spec.md @@ -0,0 +1,32 @@ +# 🧪 Template: Vitest Test Specification + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createMockRedis } from '@helix-origin/vitest-suite/storage'; +import { createMockClient, createMockInteraction } from '@helix-origin/vitest-suite/discord'; + +describe('<Feature / Component Name>', () => { + beforeEach(() => { + // Setup state + }); + + afterEach(() => { + // Clean up mocks and timers + }); + + it('should handle primary expected flow', async () => { + // Arrange + const client = createMockClient(); + + // Act + // ... + + // Assert + expect(client).toBeDefined(); + }); + + it('should cleanly handle errors and fallbacks', async () => { + // Edge case / error assertions + }); +}); +``` diff --git a/.agents/templates/wiki-page.md b/.agents/templates/wiki-page.md new file mode 100644 index 000000000..71b9a4d62 --- /dev/null +++ b/.agents/templates/wiki-page.md @@ -0,0 +1,56 @@ +# <emoji> <Page Title> + +Brief description of the document topic and audience. + +--- + +## 📑 Table of Contents +1. [Overview](#-overview) +2. [Architecture & Flow](#-architecture--flow) +3. [Configuration & Usage](#-configuration--usage) +4. [Troubleshooting](#-troubleshooting) +5. [Related Guides](#-related-guides) + +--- + +## 📖 Overview +Human-readable narrative explanation using callouts and bulleted lists. + +> [!NOTE] +> Important operational notices or best practices. + +--- + +## 📐 Architecture & Flow +```mermaid +flowchart TD + A[Step 1] --> B[Step 2] + B --> C[Step 3] +``` + +--- + +## 💻 Configuration & Usage +```bash +# Example commands +pnpm start +``` + +```typescript +// Example TypeScript usage +export const config = { + active: true +}; +``` + +--- + +## 🔍 Troubleshooting +- **Symptom**: Explanation of cause and quick fix. + +--- + +## 🔗 Related Guides +- [Home](Home) — Wiki Index +- [Architecture](Architecture) — System Architecture +- [Deployment](Deployment) — Production Deployment diff --git a/.dockerignore b/.dockerignore index 1308c228b..90f7f8e86 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ # Nextjs Files .next +**/.next # Docker Files Dockerfile @@ -11,12 +12,39 @@ docker-compose.yaml .git .github .gitignore -LICENSE -README.md -# Node Modules and lint settings +# Environment / Secrets +.env +.env*.local +docker.env.example + +# Node Modules node_modules -.prettierrc +**/node_modules + +# Build Output dist +build +.turbo +*.tsbuildinfo + +# Databases +*.sqlite +*-journal + +# Logs logs -Lavalink.jar \ No newline at end of file +*.log + +# Tooling output +.prettierrc +Lavalink.jar +plugins +application.yml +application.yaml +LICENSE* +README.md +PLAN.md +AGENTS.md +scratch +tests \ No newline at end of file diff --git a/.env.example b/.env.example index cd6d5eadf..e20dda7b0 100644 --- a/.env.example +++ b/.env.example @@ -1,34 +1,52 @@ -# DB URL -DATABASE_URL="postgresql://john:doe@localhost:5432/master-bot?schema=public" +# Database (URI connection string: SQLite stored at /data/db.sqlite, or PostgreSQL postgresql://...) +DB_URI="file:/data/db.sqlite" -# Bot Token -DISCORD_TOKEN="" +# Dashboard URLs (INTERNAL_URL binds to 0.0.0.0:3000 to listen on all interfaces, enabling public connections) +INTERNAL_URL="0.0.0.0:3000" +PUBLIC_URL="https://your-domain.com" +DISCORD_CALLBACK_URL="https://discord.com/api/oauth2/authorize?client_id=your_client_id&permissions=8&scope=bot" -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" +# Dashboard Visual Themes & Color Schemes +# Themes: dark (default), light, glassmorphism, cyberpunk, dracula, nord, emerald +# Color Schemes: default, purple, blue, emerald, rose, amber, indigo, crimson, teal, sunset, cyan +DASHBOARD_THEME="dark" +DASHBOARD_COLOR_SCHEME="default" -# Next Auth Discord Provider +# Discord Bot & OAuth Credentials +DISCORD_TOKEN="" DISCORD_CLIENT_ID="" DISCORD_CLIENT_SECRET="" -# Lavalink -LAVA_HOST="0.0.0.0" +# Lavalink Audio Engine (The only separate process) +LAVA_HOST="localhost" LAVA_PASS="youshallnotpass" LAVA_PORT=2333 LAVA_SECURE=false +LAVA_EXTERNAL=false -# Spotify +# YouTube & Cipher +YOUTUBE_REFRESH_TOKEN="" +YOUTUBE_CLIENT_ID="" +YOUTUBE_CLIENT_SECRET="" +YOUTUBE_CIPHER_URL="https://cipher.kikkia.dev/" +YOUTUBE_CIPHER_PASSWORD="" + +# Spotify Integration SPOTIFY_CLIENT_ID="" SPOTIFY_CLIENT_SECRET="" -# Twitch +# Twitch & IGDB Integration TWITCH_CLIENT_ID="" TWITCH_CLIENT_SECRET="" -# Other APIs -TENOR_API="" +# Third-Party APIs +KLIPY_API="" NEWS_API="" GENIUS_API="" -RAWG_API="" + +# Feature Toggles +LAVA_ENABLED=true +GIFS_ENABLED=true +TWITCH_ENABLED=true +NEWS_ENABLED=true +IGDB_ENABLED=true 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..8f8f788f0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,6 @@ +blank_issues_enabled: false +contact_links: + - name: 🔒 Security Vulnerability Reporting + url: https://github.com/galnir/Master-Bot/security/advisories + about: Please report security vulnerabilities privately rather than via public issues. + 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/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..315707e33 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build, Lint & Test + runs-on: ubuntu-latest + + env: + CI: "true" + SKIP_ENV_VALIDATION: "true" + DATABASE_URL: "file:./db.sqlite" + DISCORD_TOKEN: "mock_discord_token_for_ci_pipeline" + DISCORD_CLIENT_ID: "mock_discord_client_id" + DISCORD_CLIENT_SECRET: "mock_discord_client_secret" + PUBLIC_URL: "http://localhost:3000" + INTERNAL_URL: "http://localhost:3000" + NEXTAUTH_SECRET: "youshallnotpass" + NEXTAUTH_URL: "http://localhost:3000" + LAVA_ENABLED: "false" + + steps: + - name: Checkout Repository + 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: Setup Environment Variables + run: | + if [ ! -f .env ]; then + cp .env.example .env + fi + + - name: Install Dependencies + run: pnpm install --frozen-lockfile + + - 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/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index a99ff8cdc..000000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,11 +0,0 @@ -on: [pull_request] - -jobs: - prettier: - runs-on: ubuntu-latest - steps: - - name: Checkout Repository - - uses: actions/checkout@v3 - - - name: Build App - run: npx prettier . --check diff --git a/.github/workflows/wiki.yml b/.github/workflows/wiki.yml new file mode 100644 index 000000000..70adf0c12 --- /dev/null +++ b/.github/workflows/wiki.yml @@ -0,0 +1,66 @@ +name: Wiki Sync + +on: + push: + branches: + - main + paths: + - 'wiki/**' + - '.github/workflows/wiki.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + sync-wiki: + name: Sync Wiki Documentation + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Sync Wiki to GitHub Wiki Repository + env: + PAT_TOKEN: ${{ secrets.PAT_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: | + if [ -z "$PAT_TOKEN" ]; then + echo "::error::Secret PAT_TOKEN is not set in repository secrets. Please configure PAT_TOKEN with repo scope to enable GitHub Wiki synchronization." + exit 1 + fi + + WIKI_DIR="${GITHUB_WORKSPACE}/wiki" + TMP_DIR=$(mktemp -d) + WIKI_URL="https://x-access-token:${PAT_TOKEN}@github.com/${REPOSITORY}.wiki.git" + + echo "Checking GitHub Wiki repository connection..." + if git clone "$WIKI_URL" "$TMP_DIR" 2>/dev/null; then + echo "Successfully cloned existing wiki repository." + find "$TMP_DIR" -mindepth 1 -maxdepth 1 ! -name '.git' -exec rm -rf {} + + cp -a "$WIKI_DIR"/. "$TMP_DIR"/ + cd "$TMP_DIR" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add -A + if git diff --staged --quiet; then + echo "Wiki is already up to date. No changes to push." + else + git commit -m "docs: sync wiki documentation [skip ci]" + git push origin HEAD + echo "Wiki synchronization completed successfully." + fi + else + echo "Wiki clone failed (wiki repository may be empty or newly enabled). Pushing fresh initial branch..." + cd "$TMP_DIR" + git init -b master + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + cp -a "$WIKI_DIR"/. "$TMP_DIR"/ + git add -A + git commit -m "docs: initialize wiki documentation [skip ci]" + git push --force "$WIKI_URL" master:master || git push --force "$WIKI_URL" main:main + echo "Wiki initialization completed successfully." + fi diff --git a/.gitignore b/.gitignore index 8630e8a83..ddc6e6ff6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,13 @@ *.pem .env .env*.local +.youtube-oauth.json +.youtube-oauth.json.tmp +.youtube-oauth*.json +*.youtube-oauth.json + +# Local tracking plan (never commit) +scratch/ # Turbo .turbo @@ -27,6 +34,7 @@ out # Lavalink Lavalink.jar +plugins/ application.yml application.yaml @@ -41,17 +49,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/.npmrc b/.npmrc new file mode 100644 index 000000000..a174ae178 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +production=false +auto-install-peers=true +frozen-lockfile=false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..52d7b69f5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,110 @@ +# 🤖 Master-Bot Autonomous Agent Ecosystem + +## Overview +This document serves as the master operating manual and index for the autonomous AI agent ecosystem in **Master-Bot**. It coordinates the roles, skills, rules, and templates defined in the [`.agents/`](.agents/) directory. + +--- + +## 🏛️ Multi-Agent Operating Topology + +```mermaid +flowchart TD + subgraph CoreAgents [Autonomous Agent Roles] + Architect[🏛️ System Architect: architect.md] + DBEng[🗄️ Database Engineer: database-engineer.md] + AudioEng[🎵 Audio Engineer: audio-engineer.md] + TestEng[🧪 Test Engineer: test-engineer.md] + GHSpy[🐙 GitHub Specialist: github-specialist.md] + WikiSpy[📚 Wiki Specialist: wiki-specialist.md] + DashSpy[💻 Dashboard Specialist: dashboard-specialist.md] + BotSpy[🤖 Bot Specialist: bot-specialist.md] + RelEng[📦 Release Engineer: release-engineer.md] + end + + subgraph CoreSkills [Agent Capabilities] + GHExpert[🐙 gh-cli-expert.md] + IssueOrch[📋 issue-orchestrator.md] + WikiMgmt[📚 wiki-management.md] + DBFallback[🗄️ database-fallback.md] + LavaEmbed[🎵 embedded-lavalink.md] + VitestExp[🧪 vitest-suite-expert.md] + TurboOrch[🏗️ monorepo-orchestrator.md] + RelOrch[🚀 release-orchestrator.md] + end + + subgraph Governance [Rules & Quality Gates] + RuleMaster[📜 rules.md] + RuleCommit[📝 commit-standards.md] + RuleIssue[📋 issue-standards.md] + RuleWiki[📚 wiki-standards.md] + RuleCode[💻 code-conventions.md] + RuleDB[🗄️ database-fallback-rules.md] + RuleOps[⚙️ operational.md] + end + + subgraph Templates [Standardized Templates] + TCommit[📝 commit-template.md] + TIssueBug[🐛 issue-bug.md] + TIssueFeat[✨ issue-feature.md] + TIssueSub[🧩 issue-subissue.md] + TTestSpec[🧪 test-spec.md] + TWikiPage[📚 wiki-page.md] + end + + Governance --> CoreAgents + CoreAgents --> CoreSkills + CoreSkills --> Templates +``` + +--- + +## 🤖 Agents Directory ([`.agents/agents/`](.agents/agents/)) + +| Agent | Responsibility | Primary Skills | +| :--- | :--- | :--- | +| [**`architect`**](.agents/agents/architect.md) | High-level system architecture, boundaries, zero-ops invariants | `monorepo-orchestrator`, `database-fallback` | +| [**`database-engineer`**](.agents/agents/database-engineer.md) | PostgreSQL/SQLite schemas, Prisma ORM, Redis & in-memory cache | `database-fallback`, `vitest-suite-expert` | +| [**`audio-engineer`**](.agents/agents/audio-engineer.md) | Embedded Lavalink v4 server, YouTube OAuth, voice gateway | `embedded-lavalink`, `vitest-suite-expert` | +| [**`test-engineer`**](.agents/agents/test-engineer.md) | Test authoring, mocks (`@helix-origin/vitest-suite`), QA gates | `vitest-suite-expert`, `monorepo-orchestrator` | +| [**`github-specialist`**](.agents/agents/github-specialist.md) | GitHub CLI (`gh`), issue hierarchies, PR reviews, workflow runs | `gh-cli-expert`, `issue-orchestrator` | +| [**`wiki-specialist`**](.agents/agents/wiki-specialist.md) | Wiki structure, GitHub wiki links, Mermaid diagrams, sidebar & footer | `wiki-management`, `issue-orchestrator` | +| [**`dashboard-specialist`**](.agents/agents/dashboard-specialist.md) | Next.js 15 App Router, React 18, Tailwind CSS, tRPC v11 API | `monorepo-orchestrator`, `vitest-suite-expert` | +| [**`bot-specialist`**](.agents/agents/bot-specialist.md) | Discord.js v14, Sapphire framework, slash commands, session manager | `vitest-suite-expert`, `monorepo-orchestrator` | +| [**`release-engineer`**](.agents/agents/release-engineer.md) | Versioning, packaging, changelogs, GitHub releases | `release-orchestrator`, `gh-cli-expert` | + +--- + +## 🛠️ Skills Directory ([`.agents/skills/`](.agents/skills/)) + +| Skill | Description | Key Commands | +| :--- | :--- | :--- | +| [**`gh-cli-expert`**](.agents/skills/gh-cli-expert.md) | Complete guide for GitHub CLI automation (auth, issues, sub-issues, PRs, runs, releases) | `gh issue create`, `gh issue edit --add-sub-issue`, `gh run view --log-failed` | +| [**`issue-orchestrator`**](.agents/skills/issue-orchestrator.md) | Structured issue drafting with emojis, Mermaid diagrams, and atomic child sub-issues | `gh issue comment --body-file` | +| [**`wiki-management`**](.agents/skills/wiki-management.md) | GitHub Wiki standards, relative wiki links, sidebar hierarchy, and navigation footers | Markdown relative linking, Mermaid diagram styling | +| [**`database-fallback`**](.agents/skills/database-fallback.md) | Dual database (PostgreSQL/SQLite) and cache (Redis/ioredis-mock) management | `node packages/db/scripts/prepare-schema.mjs` | +| [**`embedded-lavalink`**](.agents/skills/embedded-lavalink.md) | Embedded Lavalink audio server via `@helix-origin/lavalink-server` and external node routing | `LAVA_EXTERNAL=false`, `startServer()` | +| [**`vitest-suite-expert`**](.agents/skills/vitest-suite-expert.md) | Monorepo testing using `@helix-origin/vitest-suite` mocks and presets | `defineMonorepoConfig`, `createMockRedis`, `createMockClient` | +| [**`monorepo-orchestrator`**](.agents/skills/monorepo-orchestrator.md) | Turborepo and pnpm workspace build and lint pipeline management | `pnpm lint`, `pnpm type-check`, `pnpm build` | +| [**`release-orchestrator`**](.agents/skills/release-orchestrator.md) | Release tagging, changelog generation, tarball packaging | `pnpm build`, `gh release create` | + +--- + +## 📜 Rules Directory ([`.agents/rules/`](.agents/rules/)) + +- [**`rules/commit-standards.md`**](.agents/rules/commit-standards.md): Detailed, human-readable commit messages with emojis, imperative mood, and scoped summaries. +- [**`rules/issue-standards.md`**](.agents/rules/issue-standards.md): Human-readable issue layouts with emojis, Mermaid diagrams, and dedicated GitHub sub-issues. +- [**`rules/wiki-standards.md`**](.agents/rules/wiki-standards.md): GitHub wiki link formatting, syntax-highlighted codeblocks, Mermaid visuals, and sidebar/footer pages. +- [**`rules/code-conventions.md`**](.agents/rules/code-conventions.md): TypeScript standards, workspace boundaries, and dependency isolation. +- [**`rules/database-fallback-rules.md`**](.agents/rules/database-fallback-rules.md): Zero-ops SQLite and `ioredis-mock` fallbacks with production PostgreSQL/Redis scaling. +- [**`rules/operational.md`**](.agents/rules/operational.md): Monorepo lifecycle commands, safety gates, and data loss prevention. + +--- + +## 📋 Templates Directory ([`.agents/templates/`](.agents/templates/)) + +- [**`templates/commit-template.md`**](.agents/templates/commit-template.md): Commit message template with approved emoji palette. +- [**`templates/issue-bug.md`**](.agents/templates/issue-bug.md): Defect report template with Mermaid failure flow and sub-issue links. +- [**`templates/issue-feature.md`**](.agents/templates/issue-feature.md): Feature request template with Mermaid sequence diagrams. +- [**`templates/issue-subissue.md`**](.agents/templates/issue-subissue.md): Atomic child sub-issue template. +- [**`templates/test-spec.md`**](.agents/templates/test-spec.md): Vitest test specification using `@helix-origin/vitest-suite`. +- [**`templates/wiki-page.md`**](.agents/templates/wiki-page.md): Standardized wiki page template with navigation and Mermaid diagrams. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..89db099c2 --- /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 (`/data/db.sqlite`) is created automatically on install + +### Setup Steps + +1. **Fork and Clone the Repository**: + + ```bash + git clone https://github.com/<your-username>/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) 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 +<type>(<scope>): <short imperative summary in lowercase> +``` + +#### 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) +- **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..165ca3f45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,28 +1,28 @@ -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 +ENV NEXT_TELEMETRY_DISABLED=1 +ENV DB_URI="file:/data/db.sqlite" WORKDIR "/Master-Bot" -# Ports for the Dashboard +# Default service port EXPOSE 3000 -ENV PORT 3000 +ENV PORT=3000 -# Install prerequisites and register fonts +# Install prerequisites and native dependencies for canvas and SSL 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 + apt-get install -y -q --no-install-recommends libfontconfig1 && \ + apt-get clean && rm -rf /var/lib/apt/lists/* && \ + npm install -g pnpm@8.6.7 -# Copy files to Container (Excluding whats in .dockerignore) +# Copy repository contents into container COPY ./ ./ -RUN pnpm install --ignore-scripts && pnpm -F * 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" +RUN pnpm install --frozen-lockfile -# Uncomment the following for Standalone Master-Bot Docker Container Build -# RUN pnpm db:push -# CMD ["pnpm", "-r", "start"] \ No newline at end of file +# Build all workspace packages, Prisma Client, and Next.js dashboard +RUN pnpm build + +# At container launch, sync database schema and start the unified service +CMD ["sh", "-c", "pnpm db:push && pnpm start"] \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 435503eb6..000000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 Julius Marminge - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 000000000..bdba7ad55 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,30 @@ +# 📄 MIT License + +**Master-Bot** is open-source software licensed under the [MIT License](https://opensource.org/licenses/MIT). + +--- + +### Copyright (c) 2023–2026 Master-Bot Contributors & Julius Marminge + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the **"Software"**), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +--- + +### Disclaimer + +> [!IMPORTANT] +> **THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE.** diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 000000000..7f2056c9c --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,112 @@ +# 🛡️ Privacy Policy for Master-Bot + +**Last Updated:** September 16, 2026 + +Welcome to **Master-Bot** ("we", "our", or "the Bot"). This Privacy Policy explains how Master-Bot collects, uses, stores, and protects information when you invite, configure, or interact with our Discord bot application and associated web dashboard. + +By inviting Master-Bot to your Discord server or using any of its features, you agree to the collection and use of information in accordance with this policy and the [Discord Developer Terms of Service](https://discord.com/developers/docs/policies-and-agreements/developer-terms-of-service). + +--- + +## 1. 📊 Information We Collect + +Master-Bot collects only the minimum necessary data required to deliver music playback, server moderation, support ticketing, reminders, and dashboard configuration services. + +### A. Information Stored in Persistent Database (`Prisma / SQLite / PostgreSQL`) +- **User Information**: + - Discord User ID (snowflake identifier). + - Discord Username and Avatar hash (used for session display on the dashboard and audit logging). +- **Guild (Server) Information**: + - Discord Guild ID (snowflake identifier) and Guild Name. + - Guild Owner ID. + - Server-specific preferences (volume settings, log channel ID, welcome channel ID, enabled log event masks, staff ticket manager role IDs). +- **Guild Membership Information**: + - Composite linkage of `GuildId` and `UserId` to maintain server-scoped records. +- **Custom Playlists**: + - Playlist name, track titles, track durations, and media identifiers created via `/create-playlist` or the music dashboard. +- **Support Tickets**: + - Ticket thread ID, creator User ID, guild ID, open/closed status, and creation timestamps. +- **Reminders**: + - Target User ID, Guild ID, scheduled timestamp, recurring interval rules, and reminder message text. +- **Twitch Stream Alerts**: + - Subscribed Twitch streamer usernames and designated alert text channel IDs. + +### B. Transient / In-Memory Data (`SessionManager & ioredis-mock`) +- Real-time voice channel session states (active voice connection, channel ID). +- In-flight music queue and player status. +- Interactive game state (Connect 4, Tic-Tac-Toe boards) during active matches. +- Web dashboard OAuth session tokens (encrypted using NextAuth with `NEXTAUTH_SECRET`). + +### C. Information We DO NOT Collect +- We **do not** read, record, or store the content of general chat messages across your server. Messages are only processed ephemerally when an explicit slash command (e.g., `/ban`, `/poll`, `/reminder`) is invoked. +- We **do not** listen to, record, or store voice audio packets. The audio engine functions strictly in playback/transmit mode. +- We **do not** collect financial information, billing details, real-world addresses, or government identification. + +--- + +## 2. ⚙️ How We Use Collected Information + +The data collected is used strictly to provide and maintain the features of Master-Bot: +1. **Music Playback**: Managing per-guild queues, playback loops, and resolving tracks via Lavalink v4. +2. **Server Moderation & Logging**: Emitting server audit logs (role changes, member joins/leaves, voice movements) to designated channels as configured by server administrators. +3. **Support Tickets**: Generating isolated thread discussions and exporting `.txt` transcript summaries for server staff. +4. **Reminders & Alerts**: Notifying users at their requested times and alerting servers when configured Twitch streamers go live. +5. **Dashboard Management**: Allowing verified server administrators to configure features securely through Discord OAuth2 authentication. + +--- + +## 3. 💾 Data Storage, Retention & Security + +- **Location**: Master-Bot operates under a self-hosted architecture. All persistent data resides within the local database instance (`packages/db/prisma/db.sqlite` or the server operator's configured PostgreSQL database). +- **Data Retention**: + - Guild configurations persist as long as the bot remains installed in the server. + - **Automatic Member Purge**: When a user leaves a Discord guild, Master-Bot's `clearUserGuildData` lifecycle service automatically cascades and deletes the user's tickets, temporary voice channels, playlists, reminders, and Twitch subscriptions associated with that guild. + - Reminder records are automatically marked completed or removed upon execution. +- **Security Safeguards**: + - Access to the administrative dashboard is gated by Discord OAuth2 session tokens with CSRF and cookie encryption. + - Database interactions utilize parameterized queries via Prisma ORM to prevent injection attacks. + +--- + +## 4. 🌐 Third-Party Services & Integrations + +Master-Bot interfaces with external APIs to provide specific functionality. These third parties operate under their respective privacy policies: +- **Discord API** ([Discord Privacy Policy](https://discord.com/privacy)): Primary platform for message gateway and voice transport. +- **YouTube / Google** ([Google Privacy Policy](https://policies.google.com/privacy)): Used for authorized music resolution via the device-flow OAuth plugin (`.youtube-oauth.json`). +- **Spotify** ([Spotify Privacy Policy](https://www.spotify.com/legal/privacy-policy/)): Used solely for playlist and track metadata resolution via Spotify Developer API. +- **Twitch** ([Twitch Privacy Notice](https://www.twitch.tv/p/legal/privacy-notice/)): Used for checking live broadcaster statuses. +- **Klipy / Waifu.im / NewsAPI**: Used for fetching reaction GIFs and public news headlines. + +We **do not** sell, rent, monetize, or trade your personal data to any third party, marketing firm, or data broker. + +--- + +## 5. 🗑️ Your Data Rights & Deletion Requests + +You have full control over your stored data: +- **User-Initiated Deletion**: + - Delete saved playlists anytime using `/delete-playlist`. + - Delete individual playlist songs using `/remove-from-playlist`. + - Delete active reminders using the reminder controls. +- **Complete Guild Deletion**: Removing the bot from your Discord server halts all data collection immediately. Server owners may request a complete database purge of their guild records by opening a request on our issue tracker or contacting the instance administrator. +- **Data Inquiries**: You may request an export or deletion of all records associated with your Discord `UserId` by submitting a request to the repository administrators. + +--- + +## 6. 👶 Children's Online Privacy Protection + +Master-Bot is intended for users who meet the minimum age required to use Discord (13 years of age, or the applicable legal age in your jurisdiction). We do not knowingly collect personal data from children under 13. If you become aware that a child has provided us with personal data, please contact us for immediate removal. + +--- + +## 7. 🔄 Changes to This Policy + +We may update this Privacy Policy from time to time. Any changes will be reflected with an updated "Last Updated" date at the top of this document. Continued use of Master-Bot following any revisions constitutes acceptance of the updated terms. + +--- + +## 📬 Contact & Inquiries + +If you have any questions, concerns, or data deletion requests regarding this Privacy Policy, please reach out through: +- **GitHub Repository**: [https://github.com/galnir/Master-Bot](https://github.com/galnir/Master-Bot) +- **Issue Tracker**: [https://github.com/galnir/Master-Bot/issues](https://github.com/galnir/Master-Bot/issues) diff --git a/README.md b/README.md index ef92bb096..b200d92f2 100644 --- a/README.md +++ b/README.md @@ -1,260 +1,150 @@ -# A Discord Music Bot written in TypeScript using Sapphire, discord.js, Next.js and React +# Master-Bot -[![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/) +A Discord Music and Utility Bot written in TypeScript using Sapphire, discord.js, Next.js, and React. -## System dependencies +[![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) -- [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 +## System Dependencies -Create an [application.yml](https://github.com/freyacodes/lavalink/blob/master/LavalinkServer/application.yml.example) file root folder. +- [Node.js](https://nodejs.org/) LTS (`>= 20.0.0`) +- [pnpm](https://pnpm.io/) (`npm install -g pnpm`) +- [Java 17+](https://adoptium.net/) (required to run the Lavalink audio server) -Download the latest Lavalink jar from [here](https://github.com/Cog-Creators/Lavalink-Jars/releases) and also place it in the root folder. +--- -### PostgreSQL +## Setup -#### Linux +1. **Clone the repository:** + ```bash + git clone https://github.com/galnir/Master-Bot.git + cd Master-Bot + ``` -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). +2. **Configure environment variables:** + ```bash + cp .env.example .env + ``` -#### MacOS +3. **Install dependencies:** + ```bash + pnpm install + ``` + *(Generates the Prisma client and initializes the SQLite database automatically.)* -Get [brew](https://brew.sh), then enter 'brew install postgresql'. +4. **Run the bot and dashboard:** + ```bash + pnpm dev + ``` + For production: + ```bash + pnpm build && pnpm start + ``` -#### 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: +## Settings (`.env`) -1. Open the dashboard and click on 'New' > 'Create new app', give it a name and select the closest region to you then click on 'Create app'. -2. Go to 'Resources' tab, under 'Add-ons' search for 'Heroku Postgres' and select it. Click 'Submit Order Form' and then do the same step again (create another postgres instance). -3. Click on each 'Heroku Postgres' addon you created, go to 'Settings' tab > Database Credentials > View Credentials and copy the each one's URI to either `DATABASE_URL` or `SHADOW_DB_URL` in the .env file you will be creating in the settings section. -4. Done! - -### Redis - -#### MacOS - -`brew install redis`. - -#### Windows - -Download from [here](https://redis.io/download/). - -#### Linux - -Follow the instructions [here](https://redis.io/docs/getting-started/installation/install-redis-on-linux/). - -### Settings (env) - -Create a `.env` file in the root directory and copy the contents of .env.example to it. -Note: if you are not hosting postgres on Heroku you do not need the SHADOW_DB_URL variable. +Create a `.env` file in the root directory by copying `.env.example`. ```env -# DB URL -DATABASE_URL="postgresql://john:doe@localhost:5432/master-bot?schema=public" - -# Bot Token -DISCORD_TOKEN="" +# Database (URI string: SQLite stored at /data/db.sqlite, or external PostgreSQL) +DB_URI="file:/data/db.sqlite" -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" +# Dashboard URLs +# INTERNAL_URL binds to 0.0.0.0:3000 to listen on all interfaces, allowing public connections +INTERNAL_URL="0.0.0.0:3000" +PUBLIC_URL="https://your-domain.com" +DISCORD_CALLBACK_URL="https://discord.com/api/oauth2/authorize?client_id=your_client_id&permissions=8&scope=bot" -# Next Auth Discord Provider +# Discord Bot & OAuth Credentials +DISCORD_TOKEN="" DISCORD_CLIENT_ID="" DISCORD_CLIENT_SECRET="" -# Lavalink -LAVA_HOST="0.0.0.0" +# Lavalink Audio Engine +LAVA_HOST="localhost" LAVA_PASS="youshallnotpass" LAVA_PORT=2333 LAVA_SECURE=false +LAVA_EXTERNAL=false -# Spotify +# Optional Integrations (YouTube, Spotify, Twitch, News, Genius, Klipy) +YOUTUBE_CLIENT_ID="" +YOUTUBE_CLIENT_SECRET="" +YOUTUBE_REFRESH_TOKEN="" SPOTIFY_CLIENT_ID="" SPOTIFY_CLIENT_SECRET="" - -# Twitch TWITCH_CLIENT_ID="" TWITCH_CLIENT_SECRET="" - -# Other APIs -TENOR_API="" -NEWS_API="" -GENIUS_API="" -RAWG_API="" - ``` -#### Gif features - -If you have no use in the gif commands, leave everything under 'Other APIs' empty. Same applies for Twitch, everything else is needed. - -#### 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. - -#### 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 +> 📖 **Full Configuration Guide:** Detailed documentation for all optional APIs, Lavalink nodes, and feature toggles is in the [Configuration Wiki](../../wiki/Configuration). -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': +--- -- http://localhost:3000/api/auth/callback/discord -- http://domain:3000/api/auth/callback/discord +## Commands -Make sure to change 'domain' in http://domain:3000/api/auth/callback/discord to your domain or public ip. +Master-Bot features slash commands across Music, Moderation, Utility, Entertainment, and Notifications. -#### Lavalink +| Category | Highlights | Example | +| :--- | :--- | :--- | +| 🎵 **Music** | `/play`, `/queue`, `/shuffle`, `/pause`, `/resume`, `/skip`, `/volume`, `/lyrics`, `/bassboost`, custom playlists | `/play song-name` | +| 🔨 **Moderation** | `/ban`, `/kick`, `/timeout`, `/slowmode`, `/purge` | `/timeout user: @member duration: 10m` | +| ⚙️ **Settings** | `/set` (unified server settings, logging, tickets, stream alerts) | `/set option: view` | +| 🔔 **Alerts** | YouTube live/upload notifications, Twitch live alerts | `/set option: youtube channel: @creator` | +| 😂 **Fun & GIFs** | Consolidated `/gif` command with modular tag options (`/gif [tag] [query] [target]`), `/8ball`, `/rps`, `/urban` | `/gif tag: hug target: @friend` | +| ℹ️ **General** | `/help`, `/dashboard`, `/reminder`, `/poll`, `/weather`, `/translate`, `/game-search`, `/world-news` | `/help` | -You can leave this as long as the values match your application.yml. +> 📖 **Complete Command Reference:** See the [Commands Wiki](../../wiki/Commands) for full descriptions, arguments, and permission requirements. -#### Spotify and Twitch +--- -Create an application in each platform's developer portal and paste the relevant values. +## 📚 Documentation & Wiki -#### 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` +Comprehensive guides and architectural documentation are available in the [Wiki](../../wiki/Home): -# Running the bot +- 🚀 [Getting Started](../../wiki/Getting-Started) — Prerequisites, installation, and first boot +- ⚙️ [Configuration](../../wiki/Configuration) — Environment variables, API keys, and feature flags +- 🏗️ [Architecture](../../wiki/Architecture) — Monorepo design, zero-ops SQLite fallback, and Redis caching +- ⌨️ [Commands Reference](../../wiki/Commands) — Detailed breakdown of all commands and `/set` options +- 🎵 [Music & Lavalink](../../wiki/Music) — Embedded Lavalink v4, YouTube OAuth, and audio filters +- 🌐 [Web Dashboard](../../wiki/Dashboard) — Next.js 15 App Router features and Discord OAuth2 setup +- 🛠️ [Developer Guide](../../wiki/Development-Guide) — Extending commands, listeners, and background monitors +- 🚀 [Deployment](../../wiki/Deployment) — Docker, Linux VPS (PM2/Caddy), and hosting options +- ❓ [FAQ & Troubleshooting](../../wiki/FAQ) — Solutions to common issues and questions -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! - -# Commands - -A full list of commands for use with Master Bot - -## Music - -| 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' | - -## Gifs - -| 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 | - -## Other - -| 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 | - -## Resources - -[Getting a Tenor API key](https://developers.google.com/tenor/guides/quickstart) - -[Getting a NewsAPI API key](https://newsapi.org/) - -[Getting a Genius API key](https://genius.com/api-clients/new) - -[Getting a rawg API key](https://rawg.io/apidocs) - -[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) - -[Installing Node.js on Windows](https://treehouse.github.io/installation-guides/windows/node-windows.html) - -[Installing on a Raspberry Pi](https://github.com/galnir/Master-Bot/wiki/Running-the-bot-on-a-Raspberry-Pi) - -[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) - -[Using an Internal LavaLink server](https://github.com/galnir/Master-Bot/wiki/Setting-up-LavaLink-with-an-Internal-LavaLink-server) +--- ## Contributing Fork it and submit a pull request! -Anyone is welcome to suggest new features and improve code quality! - -## Contributors ❤️ - -**⭐ [Bacon Fixation](https://github.com/Bacon-Fixation) ⭐ - Countless contributions** +Contributions, bug fixes, and feature proposals are warmly welcomed. Please see [`CONTRIBUTING.md`](CONTRIBUTING.md) for guidelines and development workflows. -[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 - -[Natemo6348](https://github.com/Natemo6348) - 'mute', 'unmute' - -[kfirmeg](https://github.com/kfirmeg) - play command flags, dockerization, docker wiki - -[rafaeldamasceno](https://github.com/rafaeldamasceno) - 'music-trivia' and Dockerfile improvements, minor tweaks +## Contributors ❤️ -[navidmafi](https://github.com/navidmafi) - 'LeaveTimeOut' and 'MaxResponseTime' options, update issue template, fix leave command +**⭐ [Bacon Fixation](https://github.com/Bacon-Fixation) ⭐ — Countless contributions** -[Kyoyo](https://github.com/NotKyoyo) - added back 'now-playing' +- [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 consolidation, Lavalink v4 engine, Next.js 15 migration, moderation suite, support ticket system, live ASCII progress bar & auto-updater +- [Natemo6348](https://github.com/Natemo6348) — `mute`, `unmute` +- [kfirmeg](https://github.com/kfirmeg) — play command flags, dockerization, docker wiki +- [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 -[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/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..c8d7ee69e --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,108 @@ +# 🔒 Security Policy for Master-Bot + +**Last Updated:** September 16, 2026 + +The **Master-Bot** team takes the security of our application, self-hosters, server communities, and dependencies seriously. This document outlines our vulnerability disclosure process, supported versions, and operational security recommendations for deploying Master-Bot in production. + +--- + +## 🛡️ Supported Versions + +We maintain security updates and patches aligned with the Master-Bot project: + +| Version | Supported | Status | Notes | +| :--- | :---: | :--- | :--- | +| **Main Monorepo** | ✅ Yes | **Active** | Next.js 15, discord.js v14, Lavalink v4, dual SQLite/PG. | +| **Legacy Multi-Process** | ❌ No | **End of Life (EOL)** | Older historical layouts prior to unified monorepo modernization. | + +--- + +## 🚨 Reporting a Vulnerability + +If you discover a security vulnerability, privilege escalation flaw, authentication bypass, or data leak within Master-Bot, **please do not open a public GitHub issue**. Publicly disclosing flaws puts other self-hosters and live Discord servers at risk. + +### 📝 How to Report Privately: +1. **GitHub Private Vulnerability Advisory (Recommended)**: + - Navigate to the repository's [Security Advisories](https://github.com/galnir/Master-Bot/security/advisories) tab. + - Click **"Report a vulnerability"** to open a confidential discussion directly with repository maintainers. +2. **Direct Contact**: + - If the advisory tool is unavailable, contact the repository maintainers via private channels on [GitHub](https://github.com/galnir/Master-Bot). + +### 📋 What to Include in Your Report: +To help us triage and resolve the issue quickly, please provide: +- **Type of Vulnerability**: (e.g., Cross-Site Scripting [XSS], SQL/Prisma injection, Server-Side Request Forgery [SSRF], Authentication bypass, Remote Code Execution [RCE]). +- **Affected Components**: Packages, files, or slash commands affected (e.g., `apps/dashboard`, `apps/bot`, `@master-bot/db`, `tRPC router`). +- **Steps to Reproduce**: Detailed, step-by-step instructions or proof-of-concept (PoC) code. +- **Potential Impact**: How an attacker could exploit the vulnerability and what assets or data could be compromised. +- **Remediation Suggestions**: Any proposed code fixes, input sanitization, or dependency upgrades (if known). + +--- + +## ⏱️ Response & Disclosure Timeline + +We adhere to coordinated vulnerability disclosure principles: + +```mermaid +flowchart LR + Report[1. Report Received] --> Ack[2. Initial Ack<br/>< 48 Hours] + Ack --> Triage[3. Triage & Validate<br/>< 5 Business Days] + Triage --> Patch[4. Patch & Verification] + Patch --> Release[5. Public Advisory & Release] +``` + +1. **Acknowledgment**: We will acknowledge receipt of your report within **48 hours**. +2. **Assessment & Confirmation**: We will investigate and confirm whether the issue is reproducible within **5 business days**. +3. **Patch Development**: We will prepare a fix in a private security branch and verify the resolution across all test suites. +4. **Coordinated Release**: A patched release tag will be deployed along with a public GitHub Security Advisory acknowledging the researcher (unless anonymity is requested). + +--- + +## 🔐 Operational Security Guidelines for Operators + +If you are self-hosting Master-Bot on a VPS or cloud instance, implement the following baseline security protections: + +### 1. 🔑 Credentials & Secrets Management +- **Never Commit Secrets**: Ensure `.env`, `.youtube-oauth.json`, and database files are listed in your `.gitignore` and never committed to source control. +- **Discord Bot Token**: Treat `DISCORD_TOKEN` as a root credential. Never share it or display it in console logs. If compromised, regenerate it immediately in the [Discord Developer Portal](https://discord.com/developers/applications). +- **NextAuth Secret**: Generate a cryptographically secure random 32+ character secret for `NEXTAUTH_SECRET`: + ```bash + openssl rand -base64 32 + ``` + +### 2. 🎵 Lavalink Audio Gateway Security +- **Change Default Password**: Never deploy Lavalink in production with the default password (`youshallnotpass`). Set a complex, high-entropy password in `LAVA_PASS`. +- **Port Isolation**: Restrict external access to port `2333` using a firewall (`ufw` or cloud security groups) so that only your bot process can reach the Lavalink audio gateway. + +### 3. 🌐 Web Dashboard & SSL / HTTPS +- **Always Enforce HTTPS**: Never run the web dashboard over plaintext HTTP in production. Terminate TLS with a reverse proxy like **Caddy** or **Nginx** with automated Let's Encrypt certificates. +- **OAuth Callback Whitelist**: In the Discord Developer Portal, strictly restrict `Redirect URIs` to your exact canonical domain (`https://your-domain.com/api/auth/callback/discord`). + +### 4. 🗄️ Database & File Permissions +- **SQLite Database**: Restrict filesystem permissions for `/data/db.sqlite` so that only the service user running Node.js has read/write access: + ```bash + chmod 600 /data/db.sqlite + ``` +- **External PostgreSQL / Redis**: If using external instances, enforce SSL/TLS encryption (`sslmode=require` or `rediss://`) and avoid using default superuser accounts. + +--- + +## 🔍 In-Scope vs. Out-of-Scope + +### In-Scope: +- Authentication or authorization bypass in the Next.js dashboard or tRPC API. +- Privilege escalation allowing standard Discord members to run restricted slash commands. +- SQL or ORM injection vectors in Prisma queries. +- Remote Code Execution (RCE) via command inputs, media scrapers, or file handlers. +- Information disclosure exposing server tokens, configuration secrets, or audit logs. + +### Out-of-Scope: +- Volumetric Denial of Service (DDoS) against Discord's infrastructure or the hosting provider. +- Social engineering, phishing, or physical attacks against maintainers or host servers. +- Issues related to third-party outages (Discord API downtime, YouTube scraping blocks). +- Vulnerabilities requiring root access to the operator's host machine. + +--- + +## 🏆 Hall of Fame + +We believe in recognizing security researchers who help keep Master-Bot and its server communities safe. Responsible disclosures will be publicly credited in our release notes and Security Advisories. diff --git a/TOS.md b/TOS.md new file mode 100644 index 000000000..7705f58ae --- /dev/null +++ b/TOS.md @@ -0,0 +1,86 @@ +# 📜 Terms of Service for Master-Bot + +**Last Updated:** September 16, 2026 + +Welcome to **Master-Bot** ("the Bot", "the Service"). These Terms of Service ("Terms") govern your access to and use of Master-Bot, its slash commands, interactive utilities, and associated web dashboard. + +By inviting Master-Bot to a Discord server, managing the Bot via the web dashboard, or interacting with any Bot command or feature, you agree to be bound by these Terms, the [Discord Terms of Service](https://discord.com/terms), and the [Discord Community Guidelines](https://discord.com/guidelines). If you do not agree to these Terms, do not invite or use the Bot. + +--- + +## 1. 📜 License & Open-Source Nature + +Master-Bot is an open-source software project distributed under the terms of its repository license ([MIT License](LICENSE.md)). You are permitted to inspect, modify, and self-host the application in accordance with the license. + +--- + +## 2. 🛡️ Permitted Use & User Obligations + +When using Master-Bot, you agree to adhere to the following standards: + +### A. Compliance with Laws and Discord Policies +- You must comply with all applicable local, state, national, and international laws. +- You must comply with all rules set forth in Discord's Terms of Service and Developer Policy. +- You must meet the minimum age requirement to use Discord (13 years of age or older, or the age of digital consent in your jurisdiction). + +### B. Server Administration Authority +- If you invite Master-Bot to a Discord server, you represent and warrant that you hold the requisite server permissions (e.g., `Manage Server` or `Administrator`) to authorize the Bot's presence and integration. + +--- + +## 3. 🚫 Prohibited Conduct & Misuse + +You agree **NOT** to engage in any of the following activities: +1. **Abuse and Exploits**: Attempting to overload, flood, spam, exploit bugs, or execute Denial-of-Service (DoS) attacks against the Bot, its APIs, or the Discord gateway. +2. **Harmful Content**: Utilizing the Bot’s broadcasting, embed generation, or announcement features to transmit hate speech, harassment, malware, pirated material, non-consensual imagery, or illegal media. +3. **Impersonation & Deception**: Using the Bot’s customization options to impersonate Discord staff, other bot developers, or real-world organizations. +4. **Circumvention**: Attempting to bypass access controls, role restrictions, or rate limiters implemented within the Bot or dashboard. + +--- + +## 4. 🎵 Audio & Third-Party Media Usage + +Master-Bot provides audio playback functionality through Lavalink v4 and external streaming platforms (YouTube, Spotify, SoundCloud, Vimeo, Twitch): +- **Personal & Server Entertainment**: The audio features are provided solely for the real-time entertainment of connected voice channel participants. +- **Third-Party Terms**: Users streaming media through Master-Bot are responsible for adhering to the respective terms of service of the media providers (including YouTube's Terms of Service and Spotify's User Agreement). +- **No Commercial Audio Broadcasts**: Master-Bot must not be used to rebroadcast commercial radio or paywalled streams without proper licensing. + +--- + +## 5. 🛑 Service Availability & Disclaimers + +- **"As-Is" Provision**: Master-Bot is provided on an **"AS IS"** and **"AS AVAILABLE"** basis without warranties of any kind, either express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, or non-infringement. +- **Uptime & Downtime**: We do not guarantee uninterrupted, error-free, or 100% continuous uptime. The Bot may experience downtime, maintenance periods, or rate limiting by third-party APIs (Discord, YouTube, Spotify). +- **Modification & Discontinuation**: We reserve the right to modify, suspend, or discontinue any feature, command, or the entire Service at any time without prior notice. + +--- + +## 6. ⚖️ Limitation of Liability + +To the fullest extent permitted by applicable law, neither the developers, contributors, nor hosting operators of Master-Bot shall be liable for any direct, indirect, incidental, special, consequential, or punitive damages, including: +- Loss of server data, playlists, tickets, or message history. +- Moderation disputes, server bans, or member sanctions resulting from use of the moderation commands. +- Account or server penalties imposed by Discord Inc. resulting from user violations of Discord's Community Guidelines. + +--- + +## 7. 🔨 Termination & Access Revocation + +We reserve the right, in our sole discretion and without prior notice, to: +- Restrict or block specific users from invoking commands if they violate these Terms. +- Blacklist specific Discord servers from running or inviting Master-Bot if the server promotes malicious activity. +- Terminate or revoke dashboard access for abusive or unauthorized behavior. + +--- + +## 8. 🔄 Modifications to Terms + +We reserve the right to revise and update these Terms of Service at our discretion. Any modifications will take effect immediately upon being posted with an updated "Last Updated" date. Your continued use of Master-Bot following any revisions signifies your acceptance of the revised Terms. + +--- + +## 📬 Contact & Inquiries + +For inquiries, permissions, or questions regarding these Terms of Service, please contact us at: +- **GitHub Repository**: [https://github.com/galnir/Master-Bot](https://github.com/galnir/Master-Bot) +- **Issue Tracker**: [https://github.com/galnir/Master-Bot/issues](https://github.com/galnir/Master-Bot/issues) diff --git a/application.yml.example b/application.yml.example new file mode 100644 index 000000000..37ceb36ce --- /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: ${PORT:${LAVA_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: "${LAVA_PASS: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..86a55249b --- /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/ # 73 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 +│ │ ├── presence/ # Dynamic rotating presence status manager +│ │ ├── reminders/ # Background reminder scheduler (30s tick) +│ │ ├── structures/ # ExtendedClient, CommandHelp, HelpRegistry, Owner helper +│ │ └── 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 embedded Lavalink startup 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](../../../wiki/Music) and the [Commands Reference](../../../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..43d428418 100644 --- a/apps/bot/package.json +++ b/apps/bot/package.json @@ -4,70 +4,94 @@ "private": true, "description": "a discord music bot with guild , gifs and misc commands", "author": "Nir Gal", - "license": "ISC", + "license": "MIT", "main": "dist/index.js", "scripts": { - "build": "pnpm with-env tsc", + "build": "pnpm with-env tsc && pnpm copy-scripts", "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", + "type-check": "tsc --noEmit", + "lint": "eslint src", + "lint:fix": "eslint src --fix", "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", + "@helix-origin/lavalink-server": "https://github.com/HELIX-Origin/Lavalink-Server/releases/download/v1.1.0/helix-origin-lavalink-server-1.1.0.tgz", "@lavalink/encoding": "^0.1.2", - "@master-bot/api": "^0.1.0", - "@napi-rs/canvas": "^0.1.44", - "@prisma/client": "^5.6.0", - "@sapphire/decorators": "^6.0.2", - "@sapphire/discord.js-utilities": "^7.1.2", + "@master-bot/db": "^0.1.0", + "@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", + "ioredis-mock": "^8.9.0", + "iso-639-1": "^3.1.6", + "lavalink-client": "2.2.0", "metadata-filter": "^1.3.0", "ncp": "^2.0.0", + "next": "^15.2.0", "node-fetch": "^3.3.2", "npm-run-all": "^4.1.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", "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" + "@master-bot/eslint-config": "^0.2.0", + "@sapphire/ts-config": "^5.0.3", + "@types/ioredis-mock": "^8.2.5", + "@types/node": "^20.19.43", + "@types/react": "^18.3.31", + "@types/react-dom": "^18.3.7", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "dotenv": "^16.6.1", + "dotenv-cli": "^7.4.4", + "eslint": "^8.57.1", + "prettier": "^3.9.6", + "tslib": "^2.8.1", + "typescript": "^5.9.3" }, "eslintConfig": { "root": true, "extends": [ - "@master-bot/eslint-config/base" - ] + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-var-requires": "off", + "no-empty": "off", + "no-async-promise-executor": "off" + } } } diff --git a/apps/bot/src/commands/fun/gif.ts b/apps/bot/src/commands/fun/gif.ts new file mode 100644 index 000000000..9574b6c0a --- /dev/null +++ b/apps/bot/src/commands/fun/gif.ts @@ -0,0 +1,171 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { EmbedHandler } from '../../lib/embeds/embedHandler'; +import { createReactionGifEmbed } from '../../lib/embeds/commands/gifs/reactionGifEmbed'; +import { searchGif } from '../../lib/gifs/searchGif'; +import { + getGifTag, + getAllGifTags, + getRandomGifTag +} from '../../lib/gifs/options/registry'; + +@ApplyOptions<Command.Options>({ + name: 'gif', + description: 'Display a reaction GIF, search GIFs, or get a 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) + .addStringOption(option => { + option + .setName('tag') + .setDescription('Select a GIF category or reaction tag (optional)') + .setRequired(false); + + for (const tag of getAllGifTags()) { + option.addChoices({ + name: `${tag.label} - ${tag.description}`, + value: tag.name + }); + } + return option; + }) + .addStringOption(option => + option + .setName('query') + .setDescription('Free-form search keyword for any GIF (optional)') + .setRequired(false) + ) + .addUserOption(option => + option + .setName('target') + .setDescription('User to direct the reaction at (for reaction tags)') + .setRequired(false) + ); + return builder; + }); + } + + public override async chatInputRun( + interaction: Command.ChatInputCommandInteraction + ) { + await interaction.deferReply(); + + const tagOption = interaction.options.getString('tag'); + const queryOption = interaction.options.getString('query'); + const targetUser = interaction.options.getUser('target'); + + // Case 1: If neither tag nor query option is passed, display a random gif + if (!tagOption && !queryOption) { + const randomTag = getRandomGifTag(); + const gifUrl = await searchGif(randomTag.searchKeyword); + + if (!gifUrl) { + return await interaction.editReply({ + content: + ':warning: Could not load a random GIF at this time. Please try again!' + }); + } + + const embed = EmbedHandler.media({ + title: `🎲 Random GIF (${randomTag.label})`, + description: randomTag.description, + image: gifUrl, + user: interaction.user + }); + + return await interaction.editReply({ embeds: [embed] }); + } + + // Case 2: Tag is selected + if (tagOption) { + const tag = getGifTag(tagOption); + const searchKeyword = tag ? tag.searchKeyword : tagOption; + const gifUrl = await searchGif(searchKeyword); + + if (!gifUrl) { + return await interaction.editReply({ + content: `:warning: Could not load a GIF for tag "**${tagOption}**". Please try again!` + }); + } + + if (tag && tag.targetSupported) { + const action = tag.formatAction + ? tag.formatAction(interaction.user, targetUser) + : `reacts with ${tag.label}!`; + + const embed = createReactionGifEmbed({ + actionDescription: `✨ ${interaction.user} ${action}`, + gifUrl, + user: interaction.user + }); + + return await interaction.editReply({ embeds: [embed] }); + } + + const embed = EmbedHandler.media({ + title: tag ? `${tag.label} GIF` : `🎬 GIF: ${tagOption}`, + description: tag?.description, + image: gifUrl, + user: interaction.user + }); + + return await interaction.editReply({ embeds: [embed] }); + } + + // Case 3: Free-form query search + const searchKeyword = queryOption!; + const gifUrl = await searchGif(searchKeyword); + + if (!gifUrl) { + return await interaction.editReply({ + content: `:warning: No GIFs found for "**${searchKeyword}**".` + }); + } + + const embed = EmbedHandler.media({ + title: `🎬 GIF: ${searchKeyword}`, + description: `Search results for "${searchKeyword}"`, + image: gifUrl, + user: interaction.user + }); + + return await interaction.editReply({ embeds: [embed] }); + } +} + +export const help: CommandHelp = { + name: 'gif', + category: 'fun', + description: 'Display a reaction GIF, search GIFs, or get a random GIF', + usage: '/gif [tag: Tag] [query: Keyword] [target: @User]', + examples: [ + '/gif', + '/gif tag: hug target: @Someone', + '/gif tag: cat', + '/gif query: celebratory dance' + ], + options: [ + { + name: 'tag', + description: + 'Preset tag (amongus, anime, baka, cat, doggo, gintama, hug, jojo, pat, slap, waifu)', + required: false + }, + { + name: 'query', + description: 'Search keyword for any GIF', + required: false + }, + { + name: 'target', + description: 'Target member for reaction tags', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/gifs/amongus.ts b/apps/bot/src/commands/gifs/amongus.ts deleted file mode 100644 index a910fc2d6..000000000 --- a/apps/bot/src/commands/gifs/amongus.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'amongus', - description: 'Replies with a random Among Us gif!', - preconditions: ['isCommandDisabled'] -}) -export class AmongUsCommand 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 - ) { - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/gifs/anime.ts b/apps/bot/src/commands/gifs/anime.ts deleted file mode 100644 index 2f1bafb0f..000000000 --- a/apps/bot/src/commands/gifs/anime.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'anime', - description: 'Replies with a random anime gif!', - preconditions: ['isCommandDisabled'] -}) -export class AnimeCommand 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 - ) { - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/gifs/baka.ts b/apps/bot/src/commands/gifs/baka.ts deleted file mode 100644 index 14053b514..000000000 --- a/apps/bot/src/commands/gifs/baka.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'baka', - description: 'Replies with a random baka gif!', - preconditions: ['isCommandDisabled'] -}) -export class BakaCommand 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 - ) { - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/gifs/cat.ts b/apps/bot/src/commands/gifs/cat.ts deleted file mode 100644 index 0f22e741f..000000000 --- a/apps/bot/src/commands/gifs/cat.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'cat', - description: 'Replies with a random 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) - ); - } - - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/gifs/doggo.ts b/apps/bot/src/commands/gifs/doggo.ts deleted file mode 100644 index e1fb397e4..000000000 --- a/apps/bot/src/commands/gifs/doggo.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'doggo', - description: 'Replies with a random 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) - ); - } - - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/gifs/gif.ts b/apps/bot/src/commands/gifs/gif.ts deleted file mode 100644 index f73d8ff78..000000000 --- a/apps/bot/src/commands/gifs/gif.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'gif', - description: 'Replies with a random gif gif!', - preconditions: ['isCommandDisabled'] -}) -export class GifCommand 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 - ) { - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/gifs/gintama.ts b/apps/bot/src/commands/gifs/gintama.ts deleted file mode 100644 index 7a9be81ff..000000000 --- a/apps/bot/src/commands/gifs/gintama.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'gintama', - 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) - ); - } - - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/gifs/hug.ts b/apps/bot/src/commands/gifs/hug.ts deleted file mode 100644 index 819cda1b4..000000000 --- a/apps/bot/src/commands/gifs/hug.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'hug', - description: 'Replies with a random hug gif!', - preconditions: ['isCommandDisabled'] -}) -export class HugCommand 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 - ) { - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/gifs/jojo.ts b/apps/bot/src/commands/gifs/jojo.ts deleted file mode 100644 index afa6a15ef..000000000 --- a/apps/bot/src/commands/gifs/jojo.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'jojo', - 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) - ); - } - - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/gifs/slap.ts b/apps/bot/src/commands/gifs/slap.ts deleted file mode 100644 index 479ab4d24..000000000 --- a/apps/bot/src/commands/gifs/slap.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'slap', - description: 'Replies with a random slap gif!', - preconditions: ['isCommandDisabled'] -}) -export class SlapCommand 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 - ) { - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/gifs/waifu.ts b/apps/bot/src/commands/gifs/waifu.ts deleted file mode 100644 index 51a3268bb..000000000 --- a/apps/bot/src/commands/gifs/waifu.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ApplyOptions } from '@sapphire/decorators'; -import { Command } from '@sapphire/framework'; -import { env } from '../../env'; - -@ApplyOptions<Command.Options>({ - name: 'waifu', - description: 'Replies with a random waifu gif!', - preconditions: ['isCommandDisabled'] -}) -export class WaifuCommand 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 - ) { - 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.' - }); - - return await interaction.reply({ content: json.results[0].url }); - } catch (e) { - return await interaction.reply({ - content: 'Something went wrong! Please try again later.' - }); - } - } -} diff --git a/apps/bot/src/commands/moderation/ban.ts b/apps/bot/src/commands/moderation/ban.ts new file mode 100644 index 000000000..7252823d6 --- /dev/null +++ b/apps/bot/src/commands/moderation/ban.ts @@ -0,0 +1,190 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { GuildMember, PermissionFlagsBits } from 'discord.js'; +import { createBanEmbed } from '../../lib/embeds/commands/moderation/banEmbed'; + +@ApplyOptions<Command.Options>({ + 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 = createBanEmbed({ + targetUser, + moderator: interaction.user, + reason + }); + + 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..30907cf6f --- /dev/null +++ b/apps/bot/src/commands/moderation/kick.ts @@ -0,0 +1,173 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { GuildMember, PermissionFlagsBits } from 'discord.js'; +import { createKickEmbed } from '../../lib/embeds/commands/moderation/kickEmbed'; + +@ApplyOptions<Command.Options>({ + 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 = createKickEmbed({ + targetUser, + moderator: interaction.user, + reason + }); + + 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<Command.Options>({ + 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..c198b568e --- /dev/null +++ b/apps/bot/src/commands/moderation/slowmode.ts @@ -0,0 +1,131 @@ +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'; +import { createSlowmodeEmbed } from '../../lib/embeds/commands/moderation/slowmodeEmbed'; + +@ApplyOptions<Command.Options>({ + 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 = createSlowmodeEmbed({ + channelId: targetChannel.id, + seconds, + moderator: interaction.user + }); + + 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..0453efc89 --- /dev/null +++ b/apps/bot/src/commands/moderation/timeout.ts @@ -0,0 +1,200 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { GuildMember, PermissionFlagsBits } from 'discord.js'; +import { createTimeoutEmbed } from '../../lib/embeds/commands/moderation/timeoutEmbed'; + +@ApplyOptions<Command.Options>({ + 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 = createTimeoutEmbed({ + targetUser, + moderator: interaction.user, + durationSeconds, + reason + }); + + 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<CommandOptions>({ 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<Node>; + 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<CommandOptions>({ 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 <playlist-name>', + 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<CommandOptions>({ @@ -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 <playlist-name>', + 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<CommandOptions>({ 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 <playlist-name>', + 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<CommandOptions>({ - 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 <position>', + 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<CommandOptions>({ 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<Node>; + 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..cacfd7cd0 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,33 +40,35 @@ 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' }) }); for (let i = 1; i <= lyricsIndex; ++i) { - let b = i - 1; + const b = i - 1; if (lyrics.trim().slice(b * 4096, i * 4096).length !== 0) { paginatedLyrics.addPageEmbed(embed => { return embed.setDescription(lyrics.slice(b * 4096, i * 4096)); @@ -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 <current-position> <new-position>', + 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<CommandOptions>({ + 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<CommandOptions>({ 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<CommandOptions>({ 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<Node>; + 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..db681d6c3 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<CommandOptions>({ @@ -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,66 +98,95 @@ 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!' }); } - let queue = music.queues.get(interaction.guildId!); + const 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[] = []; + const 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 <query> [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<CommandOptions>({ 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 <playlist-name> <location>', + 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 <position>', + 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<CommandOptions>({ @@ -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 <playlist-name> <url>', + 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 <seconds>', + 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<CommandOptions>({ - 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<CommandOptions>({ + 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<CommandOptions>({ 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<Node>; + 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 <setting>', + examples: ['/volume setting: value'], + options: [ + { + name: 'setting', + description: 'What Volume? (0 to 200)', + required: true + } + ] +}; 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 <question>', + 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<Command.Options>({ 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 <bot|server|user> [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<Command.Options>({ 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 <channel> <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 <user>', + 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<string, string[]> = { + 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<CommandOptions>({ + 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<string, User> = new Map(); + +@ApplyOptions<CommandOptions>({ + 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<string, User>(); + 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..5796948a0 --- /dev/null +++ b/apps/bot/src/commands/other/dashboard.ts @@ -0,0 +1,67 @@ +import type { CommandHelp } from '../../lib/structures/CommandHelp'; +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import { + createDashboardEmbed, + createDashboardUnavailableEmbed +} from '../../lib/embeds/commands/other/dashboardEmbed'; +import { getApplicationOwnerUser } from '../../lib/structures/owner'; + +@ApplyOptions<Command.Options>({ + 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.PUBLIC_URL?.trim() || ''; + const rawInternal = process.env.INTERNAL_URL?.trim() || ''; + const internalUrl = rawInternal + ? rawInternal.startsWith('http://') || rawInternal.startsWith('https://') + ? rawInternal + : `http://${rawInternal}` + : ''; + + if (!publicUrl && !internalUrl) { + const errorEmbed = createDashboardUnavailableEmbed(interaction.user); + return interaction.reply({ + embeds: [errorEmbed], + ephemeral: true + }); + } + + let isOwner = false; + if (internalUrl) { + const ownerUser = await getApplicationOwnerUser(this.container.client); + isOwner = Boolean(ownerUser && interaction.user.id === ownerUser.id); + } + + const embed = createDashboardEmbed({ + publicUrl, + internalUrl, + user: interaction.user, + isOwner + }); + + 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<Command.Options>({ 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 + ? `<t:${game.first_release_date}:D>` + : '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<any> { - 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 <game>', + 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..6b35dc702 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'; @@ -15,7 +16,7 @@ export class GamesCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { - let maxPlayers = 2; + const maxPlayers = 2; const playerMap = new Map(); const player1 = interaction.user; @@ -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<string, string> = { + music: '🎵', + gifs: '🖼️', + twitch: '🎮', + moderation: '🔨', + other: '⚙️' +}; + +const CATEGORY_NAMES: Record<string, string> = { + music: 'Music & Audio', + gifs: 'Reaction GIFs', + twitch: 'Twitch Live Alerts', + moderation: 'Moderation & Server Management', + other: 'Utilities & General' +}; + @ApplyOptions<CommandOptions>({ 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<StringSelectMenuBuilder>().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<string, Set<number>>, + 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**' + : `<t:${endTimeUnix}:R> (<t:${endTimeUnix}:t>)`, + 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<ButtonBuilder>[] { + const rows: ActionRowBuilder<ButtonBuilder>[] = []; + let currentRow = new ActionRowBuilder<ButtonBuilder>(); + + for (let i = 0; i < options.length; i++) { + if (i > 0 && i % 5 === 0) { + rows.push(currentRow); + currentRow = new ActionRowBuilder<ButtonBuilder>(); + } + + 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<CommandOptions>({ + 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<string, Set<number>>(); + 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<number>(); + 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: <Text> options: <Choice 1, Choice 2, ...> [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 <min> <max>', + 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..cdb9eb539 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<CommandOptions>({ 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( @@ -127,21 +128,31 @@ export class RedditCommand extends Command { sort: string, timeFilter = 'day' ) { + let data: any; try { - var data = await this.getData(subreddit, sort, timeFilter); + 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]; + const 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 +161,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 +171,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 +232,24 @@ const optionsArray = [ value: 'all' } ]; + +export const help: CommandHelp = { + name: 'reddit', + category: 'other', + description: 'Get posts from reddit by specifying a subreddit', + usage: '/reddit <subreddit> <sort>', + 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<CommandOptions>({ + 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)}** (<t:${Math.floor(targetDate.getTime() / 1000)}:R>).` + ) + .addFields( + { name: '📝 Event', value: formattedEvent, inline: true }, + { + name: '⏱️ Remind At', + value: `<t:${Math.floor(targetDate.getTime() / 1000)}:F>`, + 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: `<t:${Math.floor(targetDate.getTime() / 1000)}:R>`, + 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}** — <t:${unix}:R> (<t:${unix}:d>)${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 <set | list | delete>', + 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 <move>', + 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..adc39613c --- /dev/null +++ b/apps/bot/src/commands/other/set.ts @@ -0,0 +1,189 @@ +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 { getSetOption, getAllSetOptions } from '../../lib/set/options/registry'; + +@ApplyOptions<CommandOptions>({ + name: 'set', + description: + 'Configure server settings (Welcome, Twitch, YouTube, Logging, Tickets, Volume)', + preconditions: ['GuildOnly', 'isCommandDisabled'] +}) +export class SetCommand extends Command { + public override registerApplicationCommands( + registry: Command.Registry + ): void { + registry.registerChatInputCommand(builder => { + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(opt => { + opt + .setName('setting') + .setDescription('Setting to configure or view (defaults to view)') + .setRequired(false); + + for (const setting of getAllSetOptions()) { + opt.addChoices({ + name: `${setting.label} - ${setting.description}`.slice(0, 100), + value: setting.name + }); + } + return opt; + }) + .addChannelOption(opt => + opt + .setName('channel') + .setDescription('Target text or forum channel') + .setRequired(false) + .addChannelTypes( + ChannelType.GuildText, + ChannelType.GuildForum + ) + ) + .addStringOption(opt => + opt + .setName('value') + .setDescription('Text value (welcome message, Twitch streamer, YouTube handle)') + .setRequired(false) + ) + .addBooleanOption(opt => + opt + .setName('enabled') + .setDescription('Enable or disable setting (for toggles)') + .setRequired(false) + ) + .addRoleOption(opt => + opt + .setName('role') + .setDescription('Staff/moderator role (for ticket system)') + .setRequired(false) + ) + .addIntegerOption(opt => + opt + .setName('number') + .setDescription('Numeric value (e.g. volume 1-100)') + .setRequired(false) + .setMinValue(1) + .setMaxValue(100) + ) + .addStringOption(opt => + opt + .setName('alerts') + .setDescription('YouTube alert type') + .setRequired(false) + .addChoices( + { name: 'All (Streams & Uploads)', value: 'all' }, + { name: 'Live Streams Only', value: 'streams' }, + { name: 'Video Uploads Only', value: 'uploads' } + ) + ); + 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 settingKey = interaction.options.getString('setting') ?? 'view'; + const optionHandler = getSetOption(settingKey); + + try { + if (optionHandler) { + return await optionHandler.execute(interaction); + } + return await interaction.editReply({ + content: `:warning: Unknown setting option: \`${settingKey}\`. Use \`/set\` to view settings.` + }); + } 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, YouTube, Logging, Tickets, Volume)', + usage: + '/set [setting: Setting] [channel: #channel] [value: Text] [enabled: True/False] [role: @Role] [number: 1-100] [alerts: Type]', + examples: [ + '/set', + '/set setting: view', + '/set setting: welcome-channel channel: #welcome', + '/set setting: welcome-message value: Welcome {user} to {server}!', + '/set setting: welcome-toggle enabled: True', + '/set setting: twitch-add value: shroud channel: #streams', + '/set setting: youtube-add value: @MrBeast channel: #videos alerts: all', + '/set setting: log-channel channel: #mod-logs', + '/set setting: ticket-channel channel: #support', + '/set setting: ticket-panel', + '/set setting: ticket-role role: @SupportTeam', + '/set setting: default-volume number: 80' + ], + options: [ + { + name: 'setting', + description: 'Setting to view or configure (defaults to view)', + required: false + }, + { + name: 'channel', + description: 'Target text or forum channel', + required: false + }, + { + name: 'value', + description: + 'Text parameter (e.g. welcome message, streamer name, YouTube handle)', + required: false + }, + { + name: 'enabled', + description: 'Enable or disable toggle setting', + required: false + }, + { + name: 'role', + description: 'Staff/moderator role for ticket system', + required: false + }, + { + name: 'number', + description: 'Numeric value (e.g. volume 1-100)', + required: false + }, + { + name: 'alerts', + description: 'YouTube alert type (all, streams, uploads)', + required: false + } + ] +}; diff --git a/apps/bot/src/commands/other/speedrun.ts b/apps/bot/src/commands/other/speedrun.ts index 38edac0bd..0e08b2d6f 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'; @@ -56,7 +57,7 @@ export class SpeedRunCommand extends Command { return interaction.reply({ content: 'No game was found.' }); } - let gameID: string = initial.data[0].id; + const gameID: string = initial.data[0].id; let response; try { @@ -76,7 +77,7 @@ export class SpeedRunCommand extends Command { initial.data.slice(0, 6).forEach((id: any) => { gameNameArr.push(id.names.international); }); - let gameName = new EmbedBuilder() + const gameName = new EmbedBuilder() .setColor('Green') .setTitle(':mag: Search Results') .setThumbnail(initial.data[0].assets['cover-medium'].uri) @@ -294,7 +295,7 @@ export class SpeedRunCommand extends Command { static convertTime(time: number) { let str, hr, min: any, sec, ms: number | string | undefined; - let parts = time.toString().split('.'); + const parts = time.toString().split('.'); ms = parts.length > 1 ? parseInt((parts[1] + '00').slice(0, 3)) : undefined; sec = parseInt(parts[0]); if (sec >= 60) { @@ -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 <game> [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<string, User> = new Map(); + +@ApplyOptions<CommandOptions>({ + 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<string, User>(); + 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<CommandOptions>({ 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 <target> <text>', + 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..6d2d36655 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,14 @@ export class TVShowSearchCommand extends Command { public override async chatInputRun( interaction: Command.ChatInputCommandInteraction ) { + await interaction.deferReply(); const query = interaction.options.getString('query', true); + let data: any; try { - var data = await this.getData(query); + data = await this.getData(query); } catch (error: any) { - return interaction.reply({ content: error }); + return interaction.editReply({ content: error }); } const PaginatedEmbed = new PaginatedMessage(); @@ -72,17 +75,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<ResponseData> { + private getData(query: string): Promise<any[]> { return new Promise(async function (resolve, reject) { const url = `http://api.tvmaze.com/search/shows?q=${encodeURI(query)}`; try { @@ -101,10 +103,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 +118,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 +127,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 +152,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 +190,17 @@ type InfoObject = { thumbnail: string; }; -type Genres = string | Array<string>; - -type ResponseData = string | Array<any>; +export const help: CommandHelp = { + name: 'tv-show-search', + category: 'other', + description: 'Get TV shows information', + usage: '/tv-show-search <query>', + 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 <query>', + 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<CommandOptions>({ + 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 <location>', + 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<CommandOptions>({ + 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 ? ` • <t:${unix}:R>` : ''; + 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<CommandOptions>({ - 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<CommandOptions>({ - 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<CommandOptions>({ - 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..16ef31efa 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'; @@ -16,7 +17,7 @@ export class TwitchStatusCommand extends Command { const query = interaction.options.getString('streamer', true).toString(); try { - let user = await client.twitch.api.getUser({ + const user = await client.twitch.api.getUser({ token: client.twitch.auth.access_token, login: query }); @@ -28,7 +29,7 @@ export class TwitchStatusCommand extends Command { user_ids: [user.id] }); - let baseEmbed = new EmbedBuilder({ + const baseEmbed = new EmbedBuilder({ author: { name: `Status Check: ${ stream[0]?.type == 'live' @@ -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 <streamer>', + 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..d1ca0bbb3 100644 --- a/apps/bot/src/env.ts +++ b/apps/bot/src/env.ts @@ -1,33 +1,32 @@ -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(), + DB_URI: 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_CLIENT_ID: z.string().optional(), + YOUTUBE_CLIENT_SECRET: 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..574e0c76b 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -1,110 +1,383 @@ 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 { startYouTubeMonitor, stopYouTubeMonitor } from './lib/youtube/notifyYouTube'; +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); + } + } + + // Initialize YouTube stream and video upload alerts + startYouTubeMonitor(client); }); -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); +} + +import { startWebServer, stopWebServer } from './lib/server/webServer'; +import { + startEmbeddedLavalink, + stopEmbeddedLavalink +} from './lib/lavalink/embeddedLavalink'; +import { isUsingMockRedis, getDatabaseProvider } from '@master-bot/db'; + const main = async () => { + // Initialize embedded Lavalink if enabled and not marked external + if (isLavalinkEnabled && process.env.LAVA_EXTERNAL !== 'true') { + try { + await startEmbeddedLavalink(); + } catch (lavaErr) { + Logger.warn('Embedded Lavalink initialization note: ', lavaErr); + } + } + 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); } + + // Binding to 0.0.0.0 listens on all network interfaces rather than localhost only, + // allowing public connections routed via reverse proxy or PUBLIC_URL to reach the dashboard. + let webHost = '0.0.0.0'; + let webPort = Number.parseInt(process.env.PORT || '3000', 10); + + if (process.env.INTERNAL_URL) { + const cleanInternal = process.env.INTERNAL_URL.replace(/^https?:\/\//, ''); + const parts = cleanInternal.split(':'); + if (parts.length === 2) { + webHost = parts[0] || '0.0.0.0'; + const parsedPort = Number.parseInt(parts[1], 10); + if (!Number.isNaN(parsedPort)) { + webPort = parsedPort; + } + } else if (parts.length === 1 && !Number.isNaN(Number.parseInt(parts[0], 10))) { + webPort = Number.parseInt(parts[0], 10); + } + } + + try { + await startWebServer({ host: webHost, port: webPort, botClient: client }); + } catch (webErr) { + Logger.error('Failed to initialize internal dashboard web service: ', webErr); + } + + // 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))); + } + + const dashboardPublicUrl = + process.env.PUBLIC_URL?.trim() || process.env.NEXTAUTH_URL?.trim(); + const dashboardDisplay = dashboardPublicUrl + ? `http://${webHost}:${webPort}/dashboard | Public: ${dashboardPublicUrl}/dashboard` + : `http://${webHost}:${webPort}/dashboard`; + + const dbProviderName = getDatabaseProvider() === 'postgresql' ? 'POSTGRESQL (External)' : 'SQLITE (/data/db.sqlite Fallback)'; + const redisStatusName = isUsingMockRedis() ? 'IN-MEMORY (ioredis-mock Fallback)' : 'EXTERNAL REDIS (Connected)'; + const audioStatusName = isLavalinkEnabled + ? process.env.LAVA_EXTERNAL === 'true' + ? 'EXTERNAL (Remote Node)' + : 'EMBEDDED (@helix-origin/lavalink-server)' + : 'DISABLED'; + + console.log(` +==================================================================== + 🤖 MASTER-BOT UNIFIED CONSOLE +==================================================================== + Execution Mode: ${process.env.NODE_ENV || 'production'} + Service Port: ${webPort} + + Active Components: + • 🤖 Discord Bot: READY (@${client.user?.tag || 'Master-Bot'}) + • 🌐 Web Dashboard: RUNNING (${dashboardDisplay}) + • 🗄️ Database Layer: ${dbProviderName} + • ⚡ Redis Cache: ${redisStatusName} + • 💓 Keep-Alive Service: ${process.env.KEEP_ALIVE_ENABLED !== 'false' ? 'ENABLED (10m interval)' : 'DISABLED'} + • 🎵 Audio Engine: ${audioStatusName} + • 📺 YouTube Alerts: ACTIVE + + Endpoints: + • Web Dashboard: http://localhost:${webPort}/dashboard + • Health & Keep-Alive: http://localhost:${webPort}/health +==================================================================== +`); + }); +}; + +const cleanup = async () => { + Logger.info('🛑 Shutting down Master-Bot unified service...'); + stopYouTubeMonitor(); + await stopEmbeddedLavalink(); + stopWebServer(); + client.destroy(); + process.exit(0); }; +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); + void main(); + + + diff --git a/apps/bot/src/lib/embeds/commands/gifs/reactionGifEmbed.ts b/apps/bot/src/lib/embeds/commands/gifs/reactionGifEmbed.ts new file mode 100644 index 000000000..bd229b3bb --- /dev/null +++ b/apps/bot/src/lib/embeds/commands/gifs/reactionGifEmbed.ts @@ -0,0 +1,18 @@ +import { EmbedBuilder, type GuildMember, type User } from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface ReactionGifEmbedOptions { + actionDescription: string; + gifUrl: string; + user?: User | GuildMember; +} + +export function createReactionGifEmbed( + options: ReactionGifEmbedOptions +): EmbedBuilder { + return EmbedHandler.media({ + description: options.actionDescription, + image: options.gifUrl, + user: options.user + }); +} diff --git a/apps/bot/src/lib/embeds/commands/moderation/banEmbed.ts b/apps/bot/src/lib/embeds/commands/moderation/banEmbed.ts new file mode 100644 index 000000000..57ac27d9d --- /dev/null +++ b/apps/bot/src/lib/embeds/commands/moderation/banEmbed.ts @@ -0,0 +1,38 @@ +import { EmbedBuilder, type User } from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface BanEmbedOptions { + targetUser: User; + moderator: User; + reason?: string; +} + +export function createBanEmbed(options: BanEmbedOptions): EmbedBuilder { + const targetTag = options.targetUser.tag ?? options.targetUser.username; + const modTag = options.moderator.tag ?? options.moderator.username; + + return EmbedHandler.create({ + title: '🔨 Member Banned', + variant: 'error', + thumbnail: options.targetUser.displayAvatarURL(), + fields: [ + EmbedHandler.field( + '👤 Target Member', + `${targetTag} (<@${options.targetUser.id}>)`, + true + ), + EmbedHandler.field( + '🛡️ Moderator', + `${modTag} (<@${options.moderator.id}>)`, + true + ), + EmbedHandler.field( + '📝 Reason', + options.reason || '*No reason provided*', + false + ) + ], + footer: `User ID: ${options.targetUser.id}`, + timestamp: true + }); +} diff --git a/apps/bot/src/lib/embeds/commands/moderation/kickEmbed.ts b/apps/bot/src/lib/embeds/commands/moderation/kickEmbed.ts new file mode 100644 index 000000000..45c16259c --- /dev/null +++ b/apps/bot/src/lib/embeds/commands/moderation/kickEmbed.ts @@ -0,0 +1,38 @@ +import { EmbedBuilder, type User } from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface KickEmbedOptions { + targetUser: User; + moderator: User; + reason?: string; +} + +export function createKickEmbed(options: KickEmbedOptions): EmbedBuilder { + const targetTag = options.targetUser.tag ?? options.targetUser.username; + const modTag = options.moderator.tag ?? options.moderator.username; + + return EmbedHandler.create({ + title: '👢 Member Kicked', + variant: 'warning', + thumbnail: options.targetUser.displayAvatarURL(), + fields: [ + EmbedHandler.field( + '👤 Target Member', + `${targetTag} (<@${options.targetUser.id}>)`, + true + ), + EmbedHandler.field( + '🛡️ Moderator', + `${modTag} (<@${options.moderator.id}>)`, + true + ), + EmbedHandler.field( + '📝 Reason', + options.reason || '*No reason provided*', + false + ) + ], + footer: `User ID: ${options.targetUser.id}`, + timestamp: true + }); +} diff --git a/apps/bot/src/lib/embeds/commands/moderation/slowmodeEmbed.ts b/apps/bot/src/lib/embeds/commands/moderation/slowmodeEmbed.ts new file mode 100644 index 000000000..1cc611692 --- /dev/null +++ b/apps/bot/src/lib/embeds/commands/moderation/slowmodeEmbed.ts @@ -0,0 +1,33 @@ +import { EmbedBuilder, type User } from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface SlowmodeEmbedOptions { + channelId: string; + seconds: number; + moderator: User; +} + +export function createSlowmodeEmbed(options: SlowmodeEmbedOptions): EmbedBuilder { + const modTag = options.moderator.tag ?? options.moderator.username; + const isDisabled = options.seconds === 0; + + return EmbedHandler.create({ + title: '⏱️ Slowmode Updated', + variant: isDisabled ? 'success' : 'info', + fields: [ + EmbedHandler.field('📢 Channel', `<#${options.channelId}>`, true), + EmbedHandler.field( + '⏳ Rate Limit', + isDisabled ? '**Disabled** (0s)' : `**${options.seconds}s** per user`, + true + ), + EmbedHandler.field( + '🛡️ Moderator', + `${modTag} (<@${options.moderator.id}>)`, + false + ) + ], + user: options.moderator, + timestamp: true + }); +} diff --git a/apps/bot/src/lib/embeds/commands/moderation/timeoutEmbed.ts b/apps/bot/src/lib/embeds/commands/moderation/timeoutEmbed.ts new file mode 100644 index 000000000..4639f153e --- /dev/null +++ b/apps/bot/src/lib/embeds/commands/moderation/timeoutEmbed.ts @@ -0,0 +1,50 @@ +import { EmbedBuilder, type User } from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface TimeoutEmbedOptions { + targetUser: User; + moderator: User; + durationSeconds: number; + reason?: string; +} + +export function createTimeoutEmbed(options: TimeoutEmbedOptions): EmbedBuilder { + const targetTag = options.targetUser.tag ?? options.targetUser.username; + const modTag = options.moderator.tag ?? options.moderator.username; + const isRemoved = options.durationSeconds === 0; + + return EmbedHandler.create({ + title: isRemoved ? '🔊 Timeout Removed' : '🔇 Member Timed Out', + variant: isRemoved ? 'success' : 'warning', + thumbnail: options.targetUser.displayAvatarURL(), + fields: [ + EmbedHandler.field( + '👤 Target Member', + `${targetTag} (<@${options.targetUser.id}>)`, + true + ), + EmbedHandler.field( + '🛡️ Moderator', + `${modTag} (<@${options.moderator.id}>)`, + true + ), + EmbedHandler.field( + '⏳ Duration', + isRemoved + ? '**Removed**' + : EmbedHandler.timestamp( + Date.now() + options.durationSeconds * 1000, + 'R' + ), + true + ), + EmbedHandler.field( + '📝 Reason', + options.reason || '*No reason provided*', + false + ) + ], + footer: `User ID: ${options.targetUser.id}`, + timestamp: true + }); +} diff --git a/apps/bot/src/lib/embeds/commands/other/dashboardEmbed.ts b/apps/bot/src/lib/embeds/commands/other/dashboardEmbed.ts new file mode 100644 index 000000000..ff2b3502c --- /dev/null +++ b/apps/bot/src/lib/embeds/commands/other/dashboardEmbed.ts @@ -0,0 +1,53 @@ +import { EmbedBuilder, type User } from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface DashboardEmbedOptions { + publicUrl?: string; + internalUrl?: string; + user: User; + isOwner?: boolean; +} + +export function createDashboardEmbed( + options: DashboardEmbedOptions +): EmbedBuilder { + const fields = []; + + if (options.publicUrl) { + fields.push( + EmbedHandler.field( + '🔗 Public Dashboard', + `[Click here to open the dashboard](${options.publicUrl})`, + false + ) + ); + } + + if (options.internalUrl && options.isOwner) { + fields.push( + EmbedHandler.field( + '🏠 Internal Link (Owner)', + `[Open internal dashboard](${options.internalUrl})`, + false + ) + ); + } + + return EmbedHandler.create({ + title: '🌐 Web Dashboard', + description: + '> Manage your server settings, view audit logs, and more through the web dashboard.', + variant: 'brand', + fields, + user: options.user + }); +} + +export function createDashboardUnavailableEmbed(user: User): EmbedBuilder { + return EmbedHandler.warning({ + title: 'Dashboard Unavailable', + description: + 'The web dashboard is not currently configured for this bot instance.', + user + }); +} diff --git a/apps/bot/src/lib/embeds/commands/other/quoteEmbed.ts b/apps/bot/src/lib/embeds/commands/other/quoteEmbed.ts new file mode 100644 index 000000000..769d62fd5 --- /dev/null +++ b/apps/bot/src/lib/embeds/commands/other/quoteEmbed.ts @@ -0,0 +1,41 @@ +import { EmbedBuilder, type User } from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface QuoteEmbedOptions { + title: string; + quote: string; + author?: string; + category?: string; + thumbnail?: string; + user?: User; +} + +export function createQuoteEmbed(options: QuoteEmbedOptions): EmbedBuilder { + const description = options.author + ? `${EmbedHandler.quote(options.quote)}\n\n— **${options.author}**` + : EmbedHandler.quote(options.quote); + + return EmbedHandler.create({ + title: options.title, + description, + variant: 'brand', + thumbnail: options.thumbnail, + user: options.user + }); +} + +export function createEightBallEmbed( + question: string, + answer: string, + user?: User +): EmbedBuilder { + return EmbedHandler.create({ + title: '🎱 Magic 8-Ball', + variant: 'brand', + fields: [ + EmbedHandler.field('❓ Question', question, false), + EmbedHandler.field('🔮 Prediction', `**${answer}**`, false) + ], + user + }); +} diff --git a/apps/bot/src/lib/embeds/commands/other/reminderEmbed.ts b/apps/bot/src/lib/embeds/commands/other/reminderEmbed.ts new file mode 100644 index 000000000..05c2635a0 --- /dev/null +++ b/apps/bot/src/lib/embeds/commands/other/reminderEmbed.ts @@ -0,0 +1,55 @@ +import { EmbedBuilder, type User } from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface ReminderCreatedEmbedOptions { + reminderText: string; + dueTimestampMs: number; + user: User; +} + +export function createReminderCreatedEmbed( + options: ReminderCreatedEmbedOptions +): EmbedBuilder { + return EmbedHandler.success({ + title: 'Reminder Set', + description: `I will remind you: ${EmbedHandler.quote(options.reminderText)}`, + fields: [ + EmbedHandler.field( + '⏰ Reminding At', + EmbedHandler.timestamp(options.dueTimestampMs, 'F'), + true + ), + EmbedHandler.field( + '⏳ In', + EmbedHandler.timestamp(options.dueTimestampMs, 'R'), + true + ) + ], + user: options.user + }); +} + +export interface ReminderNotificationEmbedOptions { + reminderText: string; + createdAt?: Date | number; + user: User; +} + +export function createReminderNotificationEmbed( + options: ReminderNotificationEmbedOptions +): EmbedBuilder { + return EmbedHandler.info({ + title: '⏰ Reminder Alert', + description: `Here is your scheduled reminder:\n\n${EmbedHandler.quote(options.reminderText)}`, + fields: options.createdAt + ? [ + EmbedHandler.field( + '📅 Originally Set', + EmbedHandler.timestamp(options.createdAt, 'R'), + false + ) + ] + : undefined, + user: options.user + }); +} diff --git a/apps/bot/src/lib/embeds/commands/other/youtubeEmbed.ts b/apps/bot/src/lib/embeds/commands/other/youtubeEmbed.ts new file mode 100644 index 000000000..c81d6f582 --- /dev/null +++ b/apps/bot/src/lib/embeds/commands/other/youtubeEmbed.ts @@ -0,0 +1,104 @@ +import { EmbedBuilder, type User } from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface YouTubeUploadEmbedOptions { + channelTitle: string; + channelUrl: string; + channelLogo?: string; + videoTitle: string; + videoUrl: string; + videoId: string; + description?: string; + publishedAt?: Date | string | number; + thumbnailUrl?: string; + user?: User; +} + +export function createYouTubeUploadEmbed( + options: YouTubeUploadEmbedOptions +): EmbedBuilder { + const desc = options.description + ? `${options.description.length > 200 ? options.description.slice(0, 197) + '...' : options.description}\n\n` + : ''; + + const embed = EmbedHandler.create({ + title: `🎬 New Video: ${options.videoTitle}`, + url: options.videoUrl, + description: `${desc}▶️ **[Watch on YouTube](${options.videoUrl})**`, + color: 0xff0000, + author: { + name: options.channelTitle, + url: options.channelUrl, + iconURL: options.channelLogo + }, + image: + options.thumbnailUrl || + `https://img.youtube.com/vi/${options.videoId}/maxresdefault.jpg`, + fields: [ + EmbedHandler.field('📺 Channel', `[${options.channelTitle}](${options.channelUrl})`, true), + EmbedHandler.field( + '📅 Published', + options.publishedAt + ? EmbedHandler.timestamp(new Date(options.publishedAt), 'R') + : 'Just now', + true + ) + ], + footer: 'YouTube Upload Alert • Master-Bot', + timestamp: options.publishedAt ? new Date(options.publishedAt) : true, + user: options.user + }); + + return embed; +} + +export interface YouTubeLiveEmbedOptions { + channelTitle: string; + channelUrl: string; + channelLogo?: string; + streamTitle: string; + streamUrl: string; + videoId: string; + description?: string; + startedAt?: Date | string | number; + thumbnailUrl?: string; + user?: User; +} + +export function createYouTubeLiveEmbed( + options: YouTubeLiveEmbedOptions +): EmbedBuilder { + const desc = options.description + ? `${options.description.length > 200 ? options.description.slice(0, 197) + '...' : options.description}\n\n` + : ''; + + const embed = EmbedHandler.create({ + title: `🔴 LIVE NOW: ${options.streamTitle}`, + url: options.streamUrl, + description: `${desc}🔴 **[Watch Live Stream](${options.streamUrl})**`, + color: 0xff0033, + author: { + name: `${options.channelTitle} is Live!`, + url: options.channelUrl, + iconURL: options.channelLogo + }, + image: + options.thumbnailUrl || + `https://img.youtube.com/vi/${options.videoId}/maxresdefault.jpg`, + fields: [ + EmbedHandler.field('📺 Channel', `[${options.channelTitle}](${options.channelUrl})`, true), + EmbedHandler.field( + '⏰ Stream Started', + options.startedAt + ? EmbedHandler.timestamp(new Date(options.startedAt), 'R') + : 'Now', + true + ) + ], + footer: 'YouTube Live Alert • Master-Bot', + timestamp: options.startedAt ? new Date(options.startedAt) : true, + user: options.user + }); + + return embed; +} diff --git a/apps/bot/src/lib/embeds/embedHandler.ts b/apps/bot/src/lib/embeds/embedHandler.ts new file mode 100644 index 000000000..7f22e5ef3 --- /dev/null +++ b/apps/bot/src/lib/embeds/embedHandler.ts @@ -0,0 +1,286 @@ +import { + EmbedBuilder, + type APIEmbedField, + type ColorResolvable, + type GuildMember, + type User +} from 'discord.js'; +import { + EmbedColors, + type BaseEmbedOptions, + type CardEmbedOptions, + type EmbedVariant +} from './types'; + +const LIMITS = { + TITLE: 256, + DESCRIPTION: 4096, + FIELD_NAME: 256, + FIELD_VALUE: 1024, + FIELDS_COUNT: 25, + FOOTER_TEXT: 2048, + AUTHOR_NAME: 256 +}; + +export function truncate(text: string, maxLength: number): string { + if (!text) return ''; + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength - 3)}...`; +} + +/** + * Core Embed Layout Engine providing color systems, truncation safety, + * and markdown helpers. Modular command/event handlers extend or build upon this. + */ +export class EmbedHandler { + public static resolveColor( + variant?: EmbedVariant, + customColor?: ColorResolvable + ): ColorResolvable { + if (customColor) return customColor; + switch (variant) { + case 'success': + return EmbedColors.Success; + case 'error': + return EmbedColors.Error; + case 'warning': + return EmbedColors.Warning; + case 'info': + return EmbedColors.Info; + case 'music': + return EmbedColors.Music; + case 'twitch': + return EmbedColors.Twitch; + case 'dark': + return EmbedColors.Dark; + case 'gold': + return EmbedColors.Gold; + case 'brand': + default: + return EmbedColors.Brand; + } + } + + public static create(options: BaseEmbedOptions = {}): EmbedBuilder { + const embed = new EmbedBuilder(); + const color = this.resolveColor(options.variant, options.color); + embed.setColor(color); + + if (options.title) { + embed.setTitle(truncate(options.title, LIMITS.TITLE)); + } + + if (options.description) { + embed.setDescription(truncate(options.description, LIMITS.DESCRIPTION)); + } + + if (options.url) { + embed.setURL(options.url); + } + + if (options.thumbnail) { + embed.setThumbnail(options.thumbnail); + } + + if (options.image) { + embed.setImage(options.image); + } + + if (options.author) { + if ('username' in options.author || 'user' in options.author) { + const user = 'user' in options.author ? options.author.user : options.author; + embed.setAuthor({ + name: truncate(user.username ?? 'Unknown User', LIMITS.AUTHOR_NAME), + iconURL: user.displayAvatarURL() + }); + } else { + embed.setAuthor({ + name: truncate(options.author.name, LIMITS.AUTHOR_NAME), + iconURL: options.author.iconURL, + url: options.author.url + }); + } + } + + if (options.footer) { + if (typeof options.footer === 'string') { + embed.setFooter({ text: truncate(options.footer, LIMITS.FOOTER_TEXT) }); + } else { + embed.setFooter({ + text: truncate(options.footer.text, LIMITS.FOOTER_TEXT), + iconURL: options.footer.iconURL + }); + } + } else if (options.user) { + const u = 'user' in options.user ? options.user.user : options.user; + embed.setFooter({ + text: truncate(`Requested by ${u.username}`, LIMITS.FOOTER_TEXT), + iconURL: u.displayAvatarURL() + }); + } + + if (options.timestamp === true || options.timestamp === undefined) { + embed.setTimestamp(); + } else if (typeof options.timestamp === 'number' || options.timestamp instanceof Date) { + embed.setTimestamp(options.timestamp); + } + + if (options.fields && options.fields.length > 0) { + const validFields = options.fields + .filter((f): f is APIEmbedField => Boolean(f && f.name && f.value)) + .slice(0, LIMITS.FIELDS_COUNT) + .map(f => ({ + name: truncate(f.name, LIMITS.FIELD_NAME), + value: truncate(f.value, LIMITS.FIELD_VALUE), + inline: f.inline ?? true + })); + + if (validFields.length > 0) { + embed.addFields(validFields); + } + } + + return embed; + } + + public static success(options: BaseEmbedOptions): EmbedBuilder { + const title = options.title?.startsWith('✅') + ? options.title + : `✅ ${options.title || 'Success'}`; + return this.create({ + ...options, + variant: 'success', + title + }); + } + + public static error(options: BaseEmbedOptions): EmbedBuilder { + const title = options.title?.startsWith('❌') + ? options.title + : `❌ ${options.title || 'Error'}`; + return this.create({ + ...options, + variant: 'error', + title + }); + } + + public static warning(options: BaseEmbedOptions): EmbedBuilder { + const title = options.title?.startsWith('⚠️') + ? options.title + : `⚠️ ${options.title || 'Warning'}`; + return this.create({ + ...options, + variant: 'warning', + title + }); + } + + public static info(options: BaseEmbedOptions): EmbedBuilder { + const title = options.title?.startsWith('ℹ️') + ? options.title + : `ℹ️ ${options.title || 'Information'}`; + return this.create({ + ...options, + variant: 'info', + title + }); + } + + public static music(options: BaseEmbedOptions): EmbedBuilder { + const title = options.title?.startsWith('🎵') + ? options.title + : `🎵 ${options.title || 'Music'}`; + return this.create({ + ...options, + variant: 'music', + title + }); + } + + public static card(options: CardEmbedOptions): EmbedBuilder { + const prefix = options.badge ? `[${options.badge}] ` : ''; + const title = options.category + ? `${prefix}${options.category} • ${options.title || ''}` + : `${prefix}${options.title || ''}`; + + return this.create({ + ...options, + title, + variant: options.variant ?? 'dark' + }); + } + + public static media(options: { + description: string; + image: string; + title?: string; + user?: User | GuildMember; + color?: ColorResolvable; + }): EmbedBuilder { + return this.create({ + title: options.title, + description: options.description, + image: options.image, + variant: 'brand', + color: options.color, + user: options.user, + timestamp: false + }); + } + + public static field( + label: string, + value: string | number | boolean | null | undefined, + inline = true + ): APIEmbedField { + const safeValue = + value !== null && value !== undefined && value !== '' + ? String(value) + : '*None*'; + + return { + name: truncate(label, LIMITS.FIELD_NAME), + value: truncate(safeValue, LIMITS.FIELD_VALUE), + inline + }; + } + + public static fieldsFromRecord( + record: Record<string, string | number | boolean | null | undefined>, + inline = true + ): APIEmbedField[] { + return Object.entries(record).map(([key, val]) => + this.field(key, val, inline) + ); + } + + public static code(text: string): string { + return `\`${text.replace(/`/g, "'")}\``; + } + + public static codeBlock(code: string, lang = ''): string { + return `\`\`\`${lang}\n${code}\n\`\`\``; + } + + public static quote(text: string): string { + return text + .split('\n') + .map(line => `> ${line}`) + .join('\n'); + } + + public static list(items: string[], bullet = '•'): string { + return items.map(item => `${bullet} ${item}`).join('\n'); + } + + public static timestamp( + date: Date | number = new Date(), + style: 'R' | 'F' | 'f' | 'D' | 'd' | 'T' | 't' = 'R' + ): string { + const epoch = Math.floor( + (typeof date === 'number' ? date : date.getTime()) / 1000 + ); + return `<t:${epoch}:${style}>`; + } +} diff --git a/apps/bot/src/lib/embeds/events/tickets/ticketEmbed.ts b/apps/bot/src/lib/embeds/events/tickets/ticketEmbed.ts new file mode 100644 index 000000000..192ce753b --- /dev/null +++ b/apps/bot/src/lib/embeds/events/tickets/ticketEmbed.ts @@ -0,0 +1,94 @@ +import { + EmbedBuilder, + type Guild, + type ThreadChannel, + type User +} from 'discord.js'; +import { EmbedHandler } from '../../embedHandler'; + +export interface TicketCreatedEmbedOptions { + user: User; + guild: Guild; + thread: ThreadChannel; + message: string; + ticketRoleId?: string | null; +} + +export function createTicketCreatedEmbed( + options: TicketCreatedEmbedOptions +): EmbedBuilder { + const embed = EmbedHandler.create({ + title: `🎫 Support Ticket: ${options.user.username}`, + description: options.message, + variant: 'brand', + fields: [ + EmbedHandler.field( + '👤 Opened By', + `${options.user.tag ?? options.user.username} (<@${options.user.id}>)`, + true + ), + EmbedHandler.field('🕒 Opened At', EmbedHandler.timestamp(new Date(), 'f'), true), + ...(options.ticketRoleId + ? [EmbedHandler.field('🛡️ Support Role', `<@&${options.ticketRoleId}>`, true)] + : []) + ], + footer: { + text: `Ticket ID: ${options.thread.id} • Master-Bot Support`, + iconURL: options.guild.iconURL() || undefined + }, + timestamp: true + }); + + return embed; +} + +export interface TicketTranscriptEmbedOptions { + thread: ThreadChannel; + closedBy: User; +} + +export function createTicketTranscriptEmbed( + options: TicketTranscriptEmbedOptions +): EmbedBuilder { + return EmbedHandler.create({ + title: `📜 Ticket Transcript: ${options.thread.name}`, + variant: 'info', + fields: [ + EmbedHandler.field( + '🎫 Thread', + `${options.thread.name} (` + EmbedHandler.code(options.thread.id) + `)`, + true + ), + EmbedHandler.field( + '🛡️ Closed By', + `${options.closedBy.tag ?? options.closedBy.username} (<@${options.closedBy.id}>)`, + true + ) + ], + footer: 'A text copy of the ticket conversation is attached above.', + timestamp: true + }); +} + +export interface TicketClosedEmbedOptions { + thread: ThreadChannel; + closedBy: User; +} + +export function createTicketClosedEmbed( + options: TicketClosedEmbedOptions +): EmbedBuilder { + return EmbedHandler.warning({ + title: 'Ticket Closed', + description: `This support ticket has been closed by <@${options.closedBy.id}>.`, + fields: [ + EmbedHandler.field('🎫 Ticket', options.thread.name, true), + EmbedHandler.field( + '🛡️ Closed By', + options.closedBy.tag ?? options.closedBy.username, + true + ) + ], + timestamp: true + }); +} diff --git a/apps/bot/src/lib/embeds/types.ts b/apps/bot/src/lib/embeds/types.ts new file mode 100644 index 000000000..d0ff34d65 --- /dev/null +++ b/apps/bot/src/lib/embeds/types.ts @@ -0,0 +1,61 @@ +import type { + APIEmbedField, + ColorResolvable, + GuildMember, + User +} from 'discord.js'; + +export const EmbedColors = { + Brand: 0x5865f2, // Discord Blurple + Success: 0x57f287, // Emerald Green + Error: 0xed4245, // Crimson Red + Warning: 0xfee75c, // Amber Yellow + Info: 0x3498db, // Sky Blue + Music: 0x9b59b6, // Amethyst Purple + Twitch: 0x9146ff, // Twitch Purple + Dark: 0x2b2d31, // Neutral Dark Card + Light: 0xebeef0, // Off-white + Gold: 0xf1c40f // Trophy Gold +} as const; + +export type EmbedVariant = + | 'brand' + | 'success' + | 'error' + | 'warning' + | 'info' + | 'music' + | 'twitch' + | 'dark' + | 'gold'; + +export interface EmbedAuthorOptions { + name: string; + iconURL?: string; + url?: string; +} + +export interface EmbedFooterOptions { + text: string; + iconURL?: string; +} + +export interface BaseEmbedOptions { + title?: string; + description?: string; + variant?: EmbedVariant; + color?: ColorResolvable; + fields?: (APIEmbedField | null | undefined | false)[]; + author?: EmbedAuthorOptions | User | GuildMember; + footer?: EmbedFooterOptions | string; + thumbnail?: string | null; + image?: string | null; + url?: string; + timestamp?: Date | number | boolean; + user?: User | GuildMember; +} + +export interface CardEmbedOptions extends BaseEmbedOptions { + category?: string; + badge?: string; +} diff --git a/apps/bot/src/lib/games/connect-4.ts b/apps/bot/src/lib/games/connect-4.ts index fc584e3e0..6edeb7495 100644 --- a/apps/bot/src/lib/games/connect-4.ts +++ b/apps/bot/src/lib/games/connect-4.ts @@ -47,7 +47,7 @@ export class Connect4Game { await game(player1, player2!); async function game(player1: User, player2: User) { - let gameBoard: number[][] = [ + const gameBoard: number[][] = [ [0, 0, 0, 0, 0, 0, 0], // row 6 [0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0], @@ -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..620fef3ff 100644 --- a/apps/bot/src/lib/games/tic-tac-toe.ts +++ b/apps/bot/src/lib/games/tic-tac-toe.ts @@ -46,7 +46,7 @@ export class TicTacToeGame { player2Piece.src = Buffer.from(await player2Image.data); await game(player1, player2!); async function game(player1: User, player2: User) { - let gameBoard: number[][] = [ + const gameBoard: number[][] = [ [0, 0, 0], //row 1 [0, 0, 0], [0, 0, 0] @@ -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/options/amongusTag.ts b/apps/bot/src/lib/gifs/options/amongusTag.ts new file mode 100644 index 000000000..a40352ad9 --- /dev/null +++ b/apps/bot/src/lib/gifs/options/amongusTag.ts @@ -0,0 +1,8 @@ +import type { GifTag } from '../types'; + +export const amongusTag: GifTag = { + name: 'amongus', + label: 'Among Us', + description: 'Replies with a suspicious Among Us gif!', + searchKeyword: 'among us' +}; diff --git a/apps/bot/src/lib/gifs/options/animeTag.ts b/apps/bot/src/lib/gifs/options/animeTag.ts new file mode 100644 index 000000000..f85ad515a --- /dev/null +++ b/apps/bot/src/lib/gifs/options/animeTag.ts @@ -0,0 +1,8 @@ +import type { GifTag } from '../types'; + +export const animeTag: GifTag = { + name: 'anime', + label: 'Anime', + description: 'Replies with a random anime gif!', + searchKeyword: 'anime' +}; diff --git a/apps/bot/src/lib/gifs/options/bakaTag.ts b/apps/bot/src/lib/gifs/options/bakaTag.ts new file mode 100644 index 000000000..353c8797f --- /dev/null +++ b/apps/bot/src/lib/gifs/options/bakaTag.ts @@ -0,0 +1,13 @@ +import type { GifTag } from '../types'; + +export const bakaTag: GifTag = { + name: 'baka', + label: 'Baka', + description: 'Call someone or yourself a baka!', + searchKeyword: 'baka', + targetSupported: true, + formatAction: (user, target) => + target && target.id !== user.id + ? `thinks ${target} is a baka! 💢` + : 'realized they are a baka! 🤦' +}; diff --git a/apps/bot/src/lib/gifs/options/catTag.ts b/apps/bot/src/lib/gifs/options/catTag.ts new file mode 100644 index 000000000..91d650198 --- /dev/null +++ b/apps/bot/src/lib/gifs/options/catTag.ts @@ -0,0 +1,8 @@ +import type { GifTag } from '../types'; + +export const catTag: GifTag = { + name: 'cat', + label: 'Cat', + description: 'Replies with a cute cat gif!', + searchKeyword: 'cat' +}; diff --git a/apps/bot/src/lib/gifs/options/doggoTag.ts b/apps/bot/src/lib/gifs/options/doggoTag.ts new file mode 100644 index 000000000..0ac68b210 --- /dev/null +++ b/apps/bot/src/lib/gifs/options/doggoTag.ts @@ -0,0 +1,8 @@ +import type { GifTag } from '../types'; + +export const doggoTag: GifTag = { + name: 'doggo', + label: 'Doggo', + description: 'Replies with a cute doggo gif!', + searchKeyword: 'dog' +}; diff --git a/apps/bot/src/lib/gifs/options/gintamaTag.ts b/apps/bot/src/lib/gifs/options/gintamaTag.ts new file mode 100644 index 000000000..201a5b3ee --- /dev/null +++ b/apps/bot/src/lib/gifs/options/gintamaTag.ts @@ -0,0 +1,8 @@ +import type { GifTag } from '../types'; + +export const gintamaTag: GifTag = { + name: 'gintama', + label: 'Gintama', + description: 'Replies with a hilarious Gintama gif!', + searchKeyword: 'gintama' +}; diff --git a/apps/bot/src/lib/gifs/options/hugTag.ts b/apps/bot/src/lib/gifs/options/hugTag.ts new file mode 100644 index 000000000..9349e8c5f --- /dev/null +++ b/apps/bot/src/lib/gifs/options/hugTag.ts @@ -0,0 +1,13 @@ +import type { GifTag } from '../types'; + +export const hugTag: GifTag = { + name: 'hug', + label: 'Hug', + description: 'Give someone or yourself a warm hug!', + searchKeyword: 'hug', + targetSupported: true, + formatAction: (user, target) => + target && target.id !== user.id + ? `gives ${target} a big warm hug! 🤗` + : 'gives themselves a warm hug! 🤗' +}; diff --git a/apps/bot/src/lib/gifs/options/jojoTag.ts b/apps/bot/src/lib/gifs/options/jojoTag.ts new file mode 100644 index 000000000..122e08692 --- /dev/null +++ b/apps/bot/src/lib/gifs/options/jojoTag.ts @@ -0,0 +1,8 @@ +import type { GifTag } from '../types'; + +export const jojoTag: GifTag = { + name: 'jojo', + label: 'JoJo', + description: 'Replies with a bizarre JoJo gif!', + searchKeyword: 'jojo' +}; diff --git a/apps/bot/src/lib/gifs/options/patTag.ts b/apps/bot/src/lib/gifs/options/patTag.ts new file mode 100644 index 000000000..614a18403 --- /dev/null +++ b/apps/bot/src/lib/gifs/options/patTag.ts @@ -0,0 +1,13 @@ +import type { GifTag } from '../types'; + +export const patTag: GifTag = { + name: 'pat', + label: 'Pat', + description: 'Give someone or yourself a gentle head pat!', + searchKeyword: 'pat', + targetSupported: true, + formatAction: (user, target) => + target && target.id !== user.id + ? `pats ${target} on the head! 🥰` + : 'gives themselves a gentle head pat! 😊' +}; diff --git a/apps/bot/src/lib/gifs/options/registry.ts b/apps/bot/src/lib/gifs/options/registry.ts new file mode 100644 index 000000000..6a2056a48 --- /dev/null +++ b/apps/bot/src/lib/gifs/options/registry.ts @@ -0,0 +1,47 @@ +import type { GifTag } from '../types'; +import { amongusTag } from './amongusTag'; +import { animeTag } from './animeTag'; +import { bakaTag } from './bakaTag'; +import { catTag } from './catTag'; +import { doggoTag } from './doggoTag'; +import { gintamaTag } from './gintamaTag'; +import { hugTag } from './hugTag'; +import { jojoTag } from './jojoTag'; +import { patTag } from './patTag'; +import { slapTag } from './slapTag'; +import { waifuTag } from './waifuTag'; + +export const gifTags: Record<string, GifTag> = { + amongus: amongusTag, + anime: animeTag, + baka: bakaTag, + cat: catTag, + doggo: doggoTag, + gintama: gintamaTag, + hug: hugTag, + jojo: jojoTag, + pat: patTag, + slap: slapTag, + waifu: waifuTag +}; + +export function getGifTag(name: string): GifTag | undefined { + return gifTags[name.toLowerCase()]; +} + +export function getAllGifTags(): GifTag[] { + return Object.values(gifTags); +} + +export function getRandomGifTag(): GifTag { + const all = getAllGifTags(); + const randomIndex = Math.floor(Math.random() * all.length); + return all[randomIndex]; +} + +export function getGifChoices(): Array<{ name: string; value: string }> { + return getAllGifTags().map(tag => ({ + name: `${tag.label} - ${tag.description}`, + value: tag.name + })); +} diff --git a/apps/bot/src/lib/gifs/options/slapTag.ts b/apps/bot/src/lib/gifs/options/slapTag.ts new file mode 100644 index 000000000..ab46e415e --- /dev/null +++ b/apps/bot/src/lib/gifs/options/slapTag.ts @@ -0,0 +1,13 @@ +import type { GifTag } from '../types'; + +export const slapTag: GifTag = { + name: 'slap', + label: 'Slap', + description: 'Slap someone or yourself across the face!', + searchKeyword: 'slap', + targetSupported: true, + formatAction: (user, target) => + target && target.id !== user.id + ? `slaps ${target}! 🖐️` + : 'slaps themselves... wait why? 🤔' +}; diff --git a/apps/bot/src/lib/gifs/options/waifuTag.ts b/apps/bot/src/lib/gifs/options/waifuTag.ts new file mode 100644 index 000000000..59924de30 --- /dev/null +++ b/apps/bot/src/lib/gifs/options/waifuTag.ts @@ -0,0 +1,8 @@ +import type { GifTag } from '../types'; + +export const waifuTag: GifTag = { + name: 'waifu', + label: 'Waifu', + description: 'Replies with a cute waifu gif!', + searchKeyword: 'waifu' +}; 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<string, string[]> = { + 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<string | null> { + 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/gifs/types.ts b/apps/bot/src/lib/gifs/types.ts new file mode 100644 index 000000000..8353c1b35 --- /dev/null +++ b/apps/bot/src/lib/gifs/types.ts @@ -0,0 +1,10 @@ +import type { User } from 'discord.js'; + +export interface GifTag { + name: string; + label: string; + description: string; + searchKeyword: string; + targetSupported?: boolean; + formatAction?: (user: User, target?: User | null) => string; +} diff --git a/apps/bot/src/lib/lavalink/embeddedLavalink.ts b/apps/bot/src/lib/lavalink/embeddedLavalink.ts new file mode 100644 index 000000000..daa93293a --- /dev/null +++ b/apps/bot/src/lib/lavalink/embeddedLavalink.ts @@ -0,0 +1,105 @@ +import { startServer, LavalinkConfigError } from '@helix-origin/lavalink-server'; +import type { ServerHandle } from '@helix-origin/lavalink-server'; +import Logger from '../logger'; +import fs from 'node:fs'; +import path from 'node:path'; + +let embeddedHandle: ServerHandle | null = null; + +export interface EmbeddedLavalinkOptions { + port?: number; + host?: string; + pass?: string; + enableSupervisor?: boolean; +} + +/** + * Initializes the embedded Lavalink v4 audio server from @helix-origin/lavalink-server. + * Runs in-process alongside Master-Bot unless LAVA_EXTERNAL is explicitly set to true. + */ +export async function startEmbeddedLavalink( + options: EmbeddedLavalinkOptions = {} +): Promise<ServerHandle | null> { + if (embeddedHandle) { + return embeddedHandle; + } + + const isExternal = + (process.env.LAVA_EXTERNAL || '').toLowerCase() === 'true'; + + if (isExternal) { + Logger.info( + '[Embedded Lavalink] LAVA_EXTERNAL is enabled; skipping embedded audio server.' + ); + return null; + } + + const port = + options.port ?? + (process.env.LAVA_PORT ? Number.parseInt(process.env.LAVA_PORT, 10) : 2333); + const host = options.host ?? process.env.LAVA_HOST ?? '127.0.0.1'; + const pass = options.pass ?? process.env.LAVA_PASS ?? 'youshallnotpass'; + + // Check if Lavalink.jar exists in project root for local supervisor execution + const rootJar = path.resolve(process.cwd(), 'Lavalink.jar'); + const hasJar = fs.existsSync(rootJar); + const canSupervise = options.enableSupervisor ?? (hasJar || process.env.LAVA_JAR_AUTO_DOWNLOAD === 'true'); + + try { + Logger.info( + `[Embedded Lavalink] Initializing embedded audio server on ${host}:${port} (Supervisor: ${canSupervise ? 'ENABLED' : 'STANDBY'})...` + ); + + embeddedHandle = await startServer({ + overrides: { + gatewayHost: host, + gatewayPort: port, + internalHost: host, + internalPort: port, + pass: pass, + supervisorEnabled: canSupervise, + dashboardEnabled: false + }, + features: { + dashboard: false, + supervisor: canSupervise, + youtubeOAuth: Boolean(process.env.YOUTUBE_CLIENT_ID) + } + }); + + embeddedHandle.server.listen(port, host, () => { + Logger.info( + `[Embedded Lavalink] Embedded audio server listening at http://${host}:${port}` + ); + }); + + return embeddedHandle; + } catch (err: any) { + if (err instanceof LavalinkConfigError) { + Logger.warn( + `[Embedded Lavalink] Configuration notice: ${err.message}. Operating in external node mode.` + ); + } else { + Logger.warn( + `[Embedded Lavalink] Notice during embedded initialization: ${err?.message || err}. Continuing with configured audio node settings.` + ); + } + return null; + } +} + +/** + * Gracefully shuts down the embedded Lavalink server and process supervisor. + */ +export async function stopEmbeddedLavalink(): Promise<void> { + if (!embeddedHandle) return; + + try { + Logger.info('[Embedded Lavalink] Stopping embedded audio server...'); + await embeddedHandle.stop(); + embeddedHandle = null; + Logger.info('[Embedded Lavalink] Embedded audio server stopped.'); + } catch (err) { + Logger.error('[Embedded Lavalink] Error stopping embedded audio server:', err); + } +} 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<ActionRowBuilder<ButtonBuilder>[]> { + const isReplaying = await queue.getReplay(); - const { client } = container; - const tracks = await queue.tracks(); - const row = new ActionRowBuilder<ButtonBuilder>().addComponents( + const playbackRow = new ActionRowBuilder<ButtonBuilder>().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<ButtonBuilder>().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<string, NodeJS.Timeout>(); + +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<boolean> { + 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<void> { 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<void> { - 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<void> { - await this.player.stop(); + await this.destroyPlayer(); } public async clearTracks(): Promise<void> { @@ -396,7 +431,7 @@ export class Queue { } public async skipTo(position: number): Promise<void> { - 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..a508171de 100644 --- a/apps/bot/src/lib/music/classes/QueueClient.ts +++ b/apps/bot/src/lib/music/classes/QueueClient.ts @@ -1,30 +1,53 @@ -import Redis from 'ioredis'; -import type { RedisOptions } from 'ioredis'; -import { ConnectionInfo, Node, SendGatewayPayload } from 'lavaclient'; +import type { Redis, RedisOptions } from 'ioredis'; +import { LavalinkManager, LavalinkNodeOptions } from 'lavalink-client'; import { QueueStore } from './QueueStore'; +import { container } from '@sapphire/framework'; export interface QueueClientOptions { - redis: Redis | RedisOptions; + redis: Redis | RedisOptions | any; + 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) + options.redis as any ); + + 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..6e68f3bc1 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<string | null>; } +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<string, Queue> { public redis: ExtendedRedis; @@ -48,21 +71,24 @@ export class QueueStore extends Collection<string, Queue> { super(); this.redis = redis as any; // Redis Errors - redis.on('error', err => { - Logger.error('Redis ' + err); - }); + if (typeof this.redis?.on === 'function') { + this.redis.on('error', err => { + Logger.error('Redis ' + err); + }); + } for (const command of commands) { - this.redis.defineCommand(command.name, { - numberOfKeys: command.keys, - lua: readFileSync( - resolve( - join(__dirname, '..', '..', '..'), - 'audio', - `${command.name}.lua` - ) - ).toString() - }); + const luaCode = getLuaScript(command.name); + if (luaCode && typeof this.redis?.defineCommand === 'function') { + try { + this.redis.defineCommand(command.name, { + numberOfKeys: command.keys, + lua: luaCode + }); + } catch (cmdErr) { + Logger.warn(`Could not define custom Redis command ${command.name}: ${cmdErr}`); + } + } } } @@ -85,9 +111,6 @@ export class QueueStore extends Collection<string, Queue> { 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 +119,6 @@ export class QueueStore extends Collection<string, Queue> { [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<string, ParticipantScore> = 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<void> { + 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<void> { + 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<void> { + 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<void> { + 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<void> { + 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..637e600b9 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; @@ -12,7 +10,7 @@ export class NowPlayingEmbed { volume: number; queue?: Song[]; last?: Song; - paused?: Boolean; + paused?: boolean; public constructor( track: Song, @@ -21,7 +19,7 @@ export class NowPlayingEmbed { volume: number, queue?: Song[], last?: Song, - paused?: Boolean + paused?: boolean ) { this.track = track; this.position = position; @@ -33,31 +31,34 @@ export class NowPlayingEmbed { } public async NowPlayingEmbed(): Promise<EmbedBuilder> { - 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**: <t:${Math.floor( - new Date(streamData.started_at).getTime() / 1000 - )}:t>` - ) - .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..47e8ca9b4 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_CLIENT_ID || 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_CLIENT_ID` 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/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, `<t:${unix}:R>`); +} + +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<void> { + 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: `<t:${unix}:F> (<t:${unix}:R>)`, + 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/server/keepAlive.ts b/apps/bot/src/lib/server/keepAlive.ts new file mode 100644 index 000000000..2d0635d36 --- /dev/null +++ b/apps/bot/src/lib/server/keepAlive.ts @@ -0,0 +1,73 @@ +import axios from 'axios'; +import Logger from '../logger'; + +let keepAliveTimer: NodeJS.Timeout | null = null; + +/** + * Starts a periodic keep-alive pinger designed to prevent cloud hosts + * like Render from putting the web service to sleep due to inbound HTTP inactivity. + * + * Render spins down free-tier web services after 15 minutes without inbound HTTP traffic. + * This pinger fires every 10 minutes to maintain active service status 24/7. + */ +export function startKeepAlive(port: number): void { + if (process.env.KEEP_ALIVE_ENABLED?.toLowerCase() === 'false') { + Logger.info('Keep-alive ping service is disabled via KEEP_ALIVE_ENABLED=false.'); + return; + } + + // Resolve the public or local target URL + const rawBaseUrl = + process.env.KEEP_ALIVE_URL?.trim() || + process.env.RENDER_EXTERNAL_URL?.trim() || + process.env.PUBLIC_URL?.trim() || + process.env.NEXTAUTH_URL?.trim() || + `http://localhost:${port}`; + + const baseUrl = rawBaseUrl.replace(/\/+$/, ''); + const pingUrl = `${baseUrl}/health`; + + const intervalMs = Number.parseInt( + process.env.KEEP_ALIVE_INTERVAL_MS || String(10 * 60 * 1000), // 10 minutes default + 10 + ); + + Logger.info( + `Keep-alive service active. Target: ${pingUrl} (Interval: ${Math.round( + intervalMs / 60000 + )} min)` + ); + + const ping = async () => { + try { + const res = await axios.get(pingUrl, { + timeout: 10000, + headers: { + 'User-Agent': 'Master-Bot-KeepAlive/1.0' + }, + validateStatus: () => true // treat all HTTP statuses as non-throwing + }); + + if (res.status >= 200 && res.status < 400) { + Logger.debug(`[KeepAlive] Ping successful to ${pingUrl} (HTTP ${res.status})`); + } else { + Logger.warn(`[KeepAlive] Ping responded with HTTP ${res.status}`); + } + } catch (err: any) { + Logger.debug(`[KeepAlive] Ping notice (${pingUrl}): ${err?.message || err}`); + } + }; + + // First ping after 30 seconds, then recurring + setTimeout(() => { + void ping(); + keepAliveTimer = setInterval(() => void ping(), intervalMs); + }, 30000); +} + +export function stopKeepAlive(): void { + if (keepAliveTimer) { + clearInterval(keepAliveTimer); + keepAliveTimer = null; + } +} diff --git a/apps/bot/src/lib/server/webServer.ts b/apps/bot/src/lib/server/webServer.ts new file mode 100644 index 000000000..d20ee55bd --- /dev/null +++ b/apps/bot/src/lib/server/webServer.ts @@ -0,0 +1,137 @@ +import http from 'node:http'; +import path from 'node:path'; +import fs from 'node:fs'; +import next from 'next'; +import Logger from '../logger'; +import { startKeepAlive, stopKeepAlive } from './keepAlive'; + +export interface WebServerOptions { + port: number; + host?: string; + botClient?: any; +} + +let activeServer: http.Server | null = null; + +function resolveDashboardDir(): string { + const candidates = [ + path.resolve(process.cwd(), 'apps', 'dashboard'), + path.resolve(process.cwd(), '..', 'dashboard'), + path.resolve(__dirname, '..', '..', '..', '..', 'apps', 'dashboard'), + path.resolve(__dirname, '..', '..', '..', '..', 'dashboard'), + path.resolve(process.cwd(), 'dashboard'), + path.resolve(__dirname, 'apps', 'dashboard') + ]; + + for (const candidate of candidates) { + if ( + fs.existsSync(candidate) && + (fs.existsSync(path.join(candidate, 'package.json')) || + fs.existsSync(path.join(candidate, 'next.config.mjs'))) + ) { + return candidate; + } + } + + return path.resolve(process.cwd(), '..', 'dashboard'); +} + +/** + * Boots the internal Next.js Dashboard web server inside the same Node.js process + * as the Discord bot, listening on a single port for the entire application. + */ +export async function startWebServer( + options: WebServerOptions +): Promise<http.Server | null> { + if (process.env.DISABLE_INTERNAL_DASHBOARD === 'true') { + Logger.info('Internal dashboard server is disabled via DISABLE_INTERNAL_DASHBOARD=true.'); + return null; + } + + const port = options.port; + const host = options.host || '0.0.0.0'; + const isDev = process.env.NODE_ENV === 'development'; + const dashboardDir = resolveDashboardDir(); + + Logger.info( + `Initializing internal Next.js Dashboard service on port ${port} from ${dashboardDir}...` + ); + + if (options.botClient) { + (globalThis as any).botClient = options.botClient; + } + + const createNextApp = typeof next === 'function' ? next : (next as any).default || next; + const app = createNextApp({ + dev: isDev, + dir: dashboardDir, + hostname: host, + port + }); + + const handle = app.getRequestHandler(); + + try { + await app.prepare(); + } catch (err) { + Logger.error('Failed to prepare Next.js dashboard application: ', err); + throw err; + } + + const server = http.createServer((req, res) => { + const url = req.url || ''; + + // Platform health checks & keep-alive endpoints + if ( + url === '/health' || + url === '/api/health' || + url === '/keep-alive' + ) { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store' + }); + res.end( + JSON.stringify({ + status: 'ok', + service: 'Master-Bot', + uptime: Math.floor(process.uptime()), + timestamp: Date.now(), + botReady: options.botClient?.isReady?.() ?? false + }) + ); + return; + } + + // Forward all dashboard and web routes to Next.js handler + handle(req, res); + }); + + return new Promise((resolve, reject) => { + server.on('error', err => { + Logger.error(`Internal Dashboard Web Server encountered an error: `, err); + reject(err); + }); + + server.listen(port, host, () => { + activeServer = server; + const displayHost = host === '0.0.0.0' ? 'localhost' : host; + Logger.info( + `✅ Web Dashboard active on port ${port} (http://${displayHost}:${port}/dashboard)` + ); + + // Start background keep-alive pinging + startKeepAlive(port); + + resolve(server); + }); + }); +} + +export function stopWebServer(): void { + stopKeepAlive(); + if (activeServer) { + activeServer.close(); + activeServer = null; + } +} diff --git a/apps/bot/src/lib/session/SessionManager.ts b/apps/bot/src/lib/session/SessionManager.ts new file mode 100644 index 000000000..83df4fb79 --- /dev/null +++ b/apps/bot/src/lib/session/SessionManager.ts @@ -0,0 +1,165 @@ +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 { createYouTubeConfigHandlers } from './handlers/youtubeConfig'; +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, + YouTubeNotification, + 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<typeof createUsersHandlers>; + public readonly guildData: ReturnType<typeof createGuildDataHandlers>; + public readonly welcomeMessages: ReturnType<typeof createWelcomeMessagesHandlers>; + public readonly tickets: ReturnType<typeof createTicketsHandlers>; + public readonly twitchConfig: ReturnType<typeof createTwitchConfigHandlers>; + public readonly youtubeConfig: ReturnType<typeof createYouTubeConfigHandlers>; + public readonly hubChannels: ReturnType<typeof createHubChannelsHandlers>; + public readonly playlists: ReturnType<typeof createPlaylistsHandlers>; + public readonly songs: ReturnType<typeof createSongsHandlers>; + public readonly reminders: ReturnType<typeof createRemindersHandlers>; + public readonly commands: ReturnType<typeof createCommandsHandlers>; + public readonly members: ReturnType<typeof createMembersHandlers>; + + 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.youtubeConfig = createYouTubeConfigHandlers(this.store); + 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<void> { + await this.store.init(); + } + + public get guilds(): Map<string, GuildRecord> { + 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..7736c69d8 --- /dev/null +++ b/apps/bot/src/lib/session/SessionStore.ts @@ -0,0 +1,432 @@ +import { PrismaClient } from '@prisma/client'; +import type { + GuildRecord, + MemberRecord, + Playlist, + Reminder, + SongRecord, + TempChannel, + Ticket, + TwitchNotification, + UserRecord, + YouTubeNotification, + YouTubeTargetChannel +} 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<string, UserRecord> = new Map(); + public guilds: Map<string, GuildRecord> = new Map(); + public ticketsMap: Map<string, Ticket> = new Map(); + public tempChannels: Map<string, TempChannel> = new Map(); + public twitchNotifications: Map<string, TwitchNotification> = new Map(); + public youtubeNotifications: Map<string, YouTubeNotification> = new Map(); + public playlistsMap: Map<string, Map<string, Playlist>> = new Map(); + public remindersMap: Map<string, Reminder> = new Map(); + public membersMap: Map<string, MemberRecord> = 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<void> { + 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 dbYouTube = await this.db.youTubeNotify.findMany(); + for (const yn of dbYouTube) { + let targets: YouTubeTargetChannel[] = []; + try { + targets = JSON.parse(yn.channelIds); + } catch { + targets = []; + } + this.youtubeNotifications.set(yn.channelId, { + channelId: yn.channelId, + channelTitle: yn.channelTitle, + logo: yn.logo || undefined, + lastVideoId: yn.lastVideoId || undefined, + lastStreamId: yn.lastStreamId || undefined, + isLive: yn.isLive, + channelIds: targets + }); + } + + 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<unknown> = Promise.resolve(); + + public persist(operation: () => Promise<unknown>): 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<void> { + 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<string | null> { + 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<string, Playlist> { + 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<void> { + 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; + } + + public async ensureYouTubeRow( + notification: YouTubeNotification + ): Promise<void> { + await this.db.youTubeNotify.upsert({ + where: { channelId: notification.channelId }, + create: { + channelId: notification.channelId, + channelTitle: notification.channelTitle, + logo: notification.logo ?? '', + lastVideoId: notification.lastVideoId ?? null, + lastStreamId: notification.lastStreamId ?? null, + isLive: notification.isLive, + channelIds: JSON.stringify(notification.channelIds) + }, + update: { + channelTitle: notification.channelTitle, + logo: notification.logo ?? '', + lastVideoId: notification.lastVideoId ?? null, + lastStreamId: notification.lastStreamId ?? null, + isLive: notification.isLive, + channelIds: JSON.stringify(notification.channelIds) + } + }); + } + + public getOrCreateYouTubeNotification( + channelId: string, + channelTitle = '' + ): YouTubeNotification { + let notification = this.youtubeNotifications.get(channelId); + if (!notification) { + notification = { + channelId, + channelTitle, + isLive: false, + channelIds: [] + }; + this.youtubeNotifications.set(channelId, 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<Omit<SongRecord, 'id'>>; + }): 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<typeof createGuildDataHandlers>; + +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/handlers/youtubeConfig.ts b/apps/bot/src/lib/session/handlers/youtubeConfig.ts new file mode 100644 index 000000000..7d2ca83a7 --- /dev/null +++ b/apps/bot/src/lib/session/handlers/youtubeConfig.ts @@ -0,0 +1,103 @@ +import type { YouTubeNotification, YouTubeAlertType } from '../types'; +import type { SessionStore } from '../SessionStore'; + +export function createYouTubeConfigHandlers(store: SessionStore) { + return { + addSubscription: (input: { + channelId: string; + channelTitle: string; + logo?: string; + discordChannelId: string; + alertType: YouTubeAlertType; + }): YouTubeNotification => { + const notification = store.getOrCreateYouTubeNotification( + input.channelId, + input.channelTitle + ); + + notification.channelTitle = input.channelTitle; + if (input.logo) notification.logo = input.logo; + + // Add or update the discord channel target + const existingIdx = notification.channelIds.findIndex( + c => c.channelId === input.discordChannelId + ); + + if (existingIdx >= 0) { + notification.channelIds[existingIdx].alertType = input.alertType; + } else { + notification.channelIds.push({ + channelId: input.discordChannelId, + alertType: input.alertType + }); + } + + store.persist(async () => { + await store.ensureYouTubeRow(notification); + }); + + return notification; + }, + + removeSubscription: (input: { + channelId: string; + discordChannelId: string; + }): { success: boolean; remainingCount: number } => { + const notification = store.youtubeNotifications.get(input.channelId); + if (!notification) { + return { success: false, remainingCount: 0 }; + } + + notification.channelIds = notification.channelIds.filter( + c => c.channelId !== input.discordChannelId + ); + + if (notification.channelIds.length === 0) { + store.youtubeNotifications.delete(input.channelId); + store.persist(async () => { + await store.db.youTubeNotify.delete({ + where: { channelId: input.channelId } + }).catch(() => null); + }); + return { success: true, remainingCount: 0 }; + } else { + store.persist(async () => { + await store.ensureYouTubeRow(notification); + }); + return { success: true, remainingCount: notification.channelIds.length }; + } + }, + + getSubscription: (channelId: string): YouTubeNotification | null => { + return store.youtubeNotifications.get(channelId) ?? null; + }, + + getAllSubscriptions: (): YouTubeNotification[] => { + return Array.from(store.youtubeNotifications.values()); + }, + + updateStatus: (input: { + channelId: string; + lastVideoId?: string; + lastStreamId?: string; + isLive: boolean; + }): YouTubeNotification | null => { + const notification = store.youtubeNotifications.get(input.channelId); + if (!notification) return null; + + if (input.lastVideoId !== undefined) { + notification.lastVideoId = input.lastVideoId; + } + if (input.lastStreamId !== undefined) { + notification.lastStreamId = input.lastStreamId; + } + notification.isLive = input.isLive; + + store.persist(async () => { + await store.ensureYouTubeRow(notification); + }); + + return notification; + } + }; +} diff --git a/apps/bot/src/lib/session/types.ts b/apps/bot/src/lib/session/types.ts new file mode 100644 index 000000000..4a9e056ee --- /dev/null +++ b/apps/bot/src/lib/session/types.ts @@ -0,0 +1,119 @@ +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 type YouTubeAlertType = 'all' | 'streams' | 'uploads'; + +export interface YouTubeTargetChannel { + channelId: string; + alertType: YouTubeAlertType; +} + +export interface YouTubeNotification { + channelId: string; + channelTitle: string; + logo?: string; + lastVideoId?: string; + lastStreamId?: string; + isLive: boolean; + channelIds: YouTubeTargetChannel[]; +} + +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/options/defaultVolumeOption.ts b/apps/bot/src/lib/set/options/defaultVolumeOption.ts new file mode 100644 index 000000000..2525bc573 --- /dev/null +++ b/apps/bot/src/lib/set/options/defaultVolumeOption.ts @@ -0,0 +1,29 @@ +import { container } from '@sapphire/framework'; +import type { SetOption } from './types'; + +export const defaultVolumeOption: SetOption = { + name: 'default-volume', + label: 'Default Volume', + description: 'Set default playback volume for this server (1-100)', + execute: async interaction => { + const volume = + interaction.options.getInteger('number') ?? + interaction.options.getInteger('volume'); + + if (volume === null || volume === undefined || volume < 1 || volume > 100) { + return await interaction.editReply({ + content: + ':x: Please provide a valid `number` option between 1 and 100.\n> Example: `/set setting: default-volume number: 75`' + }); + } + + 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}%**.` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/logChannelOption.ts b/apps/bot/src/lib/set/options/logChannelOption.ts new file mode 100644 index 000000000..a09be61f3 --- /dev/null +++ b/apps/bot/src/lib/set/options/logChannelOption.ts @@ -0,0 +1,26 @@ +import { container } from '@sapphire/framework'; +import type { SetOption } from './types'; + +export const logChannelOption: SetOption = { + name: 'log-channel', + label: 'Log Channel', + description: 'Set audit/moderation log channel', + execute: async interaction => { + const channel = interaction.options.getChannel('channel'); + if (!channel) { + return await interaction.editReply({ + content: + ':x: Please provide the `channel` option.\n> Example: `/set setting: log-channel channel: #mod-logs`' + }); + } + + 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}>.` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/logDisableOption.ts b/apps/bot/src/lib/set/options/logDisableOption.ts new file mode 100644 index 000000000..e8f15afd5 --- /dev/null +++ b/apps/bot/src/lib/set/options/logDisableOption.ts @@ -0,0 +1,9 @@ +import { handleLogDisable } from '../logging'; +import type { SetOption } from './types'; + +export const logDisableOption: SetOption = { + name: 'log-disable', + label: 'Log Disable', + description: 'Disable server audit & moderation logging', + execute: handleLogDisable +}; diff --git a/apps/bot/src/lib/set/options/logToggleOption.ts b/apps/bot/src/lib/set/options/logToggleOption.ts new file mode 100644 index 000000000..57b7db27e --- /dev/null +++ b/apps/bot/src/lib/set/options/logToggleOption.ts @@ -0,0 +1,28 @@ +import { container } from '@sapphire/framework'; +import type { SetOption } from './types'; + +export const logToggleOption: SetOption = { + name: 'log-toggle', + label: 'Log Toggle', + description: 'Toggle server audit and moderation logging', + execute: async interaction => { + const enabled = interaction.options.getBoolean('enabled'); + if (enabled === null || enabled === undefined) { + return await interaction.editReply({ + content: + ':x: Please provide the `enabled` option (`True` or `False`).\n> Example: `/set setting: log-toggle 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' + }**.` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/registry.ts b/apps/bot/src/lib/set/options/registry.ts new file mode 100644 index 000000000..1c4a389c8 --- /dev/null +++ b/apps/bot/src/lib/set/options/registry.ts @@ -0,0 +1,63 @@ +import type { SetOption } from './types'; +import { viewOption } from './viewOption'; +import { welcomeChannelOption } from './welcomeChannelOption'; +import { welcomeMessageOption } from './welcomeMessageOption'; +import { welcomeToggleOption } from './welcomeToggleOption'; +import { welcomeTestOption } from './welcomeTestOption'; +import { twitchAddOption } from './twitchAddOption'; +import { twitchRemoveOption } from './twitchRemoveOption'; +import { twitchListOption } from './twitchListOption'; +import { youtubeAddOption } from './youtubeAddOption'; +import { youtubeRemoveOption } from './youtubeRemoveOption'; +import { youtubeListOption } from './youtubeListOption'; +import { logChannelOption } from './logChannelOption'; +import { logToggleOption } from './logToggleOption'; +import { logDisableOption } from './logDisableOption'; +import { ticketChannelOption } from './ticketChannelOption'; +import { ticketToggleOption } from './ticketToggleOption'; +import { ticketPanelOption } from './ticketPanelOption'; +import { ticketTranscriptOption } from './ticketTranscriptOption'; +import { ticketTranscriptDisableOption } from './ticketTranscriptDisableOption'; +import { ticketRoleOption } from './ticketRoleOption'; +import { ticketRoleDisableOption } from './ticketRoleDisableOption'; +import { defaultVolumeOption } from './defaultVolumeOption'; + +export const setOptions: Record<string, SetOption> = { + view: viewOption, + 'welcome-channel': welcomeChannelOption, + 'welcome-message': welcomeMessageOption, + 'welcome-toggle': welcomeToggleOption, + 'welcome-test': welcomeTestOption, + 'twitch-add': twitchAddOption, + 'twitch-remove': twitchRemoveOption, + 'twitch-list': twitchListOption, + 'youtube-add': youtubeAddOption, + 'youtube-remove': youtubeRemoveOption, + 'youtube-list': youtubeListOption, + 'log-channel': logChannelOption, + 'log-toggle': logToggleOption, + 'log-disable': logDisableOption, + 'ticket-channel': ticketChannelOption, + 'ticket-toggle': ticketToggleOption, + 'ticket-panel': ticketPanelOption, + 'ticket-transcript': ticketTranscriptOption, + 'ticket-transcript-disable': ticketTranscriptDisableOption, + 'ticket-role': ticketRoleOption, + 'ticket-role-disable': ticketRoleDisableOption, + 'default-volume': defaultVolumeOption +}; + +export function getSetOption(name: string): SetOption | undefined { + return setOptions[name.toLowerCase()]; +} + +export function getAllSetOptions(): SetOption[] { + return Object.values(setOptions); +} + +export function getSetChoices(): Array<{ name: string; value: string }> { + return getAllSetOptions().map(opt => ({ + name: `${opt.label} - ${opt.description}`.slice(0, 100), + value: opt.name + })); +} diff --git a/apps/bot/src/lib/set/options/ticketChannelOption.ts b/apps/bot/src/lib/set/options/ticketChannelOption.ts new file mode 100644 index 000000000..c47faf342 --- /dev/null +++ b/apps/bot/src/lib/set/options/ticketChannelOption.ts @@ -0,0 +1,27 @@ +import { container } from '@sapphire/framework'; +import type { TextChannel } from 'discord.js'; +import type { SetOption } from './types'; + +export const ticketChannelOption: SetOption = { + name: 'ticket-channel', + label: 'Ticket Channel', + description: 'Set channel where support ticket threads or panels are hosted', + execute: async interaction => { + const channel = interaction.options.getChannel('channel') as TextChannel | null; + if (!channel) { + return await interaction.editReply({ + content: + ':x: Please provide the `channel` option.\n> Example: `/set setting: ticket-channel channel: #support`' + }); + } + + await container.client.session.tickets.setChannel({ + guildId: interaction.guildId!, + channelId: channel.id + }); + + return await interaction.editReply({ + content: `:white_check_mark: Support ticket panel channel set to <#${channel.id}>.` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/ticketPanelOption.ts b/apps/bot/src/lib/set/options/ticketPanelOption.ts new file mode 100644 index 000000000..d98b92010 --- /dev/null +++ b/apps/bot/src/lib/set/options/ticketPanelOption.ts @@ -0,0 +1,9 @@ +import { handleTicketPanel } from '../tickets'; +import type { SetOption } from './types'; + +export const ticketPanelOption: SetOption = { + name: 'ticket-panel', + label: 'Ticket Panel', + description: 'Post an interactive support ticket creation embed panel', + execute: handleTicketPanel +}; diff --git a/apps/bot/src/lib/set/options/ticketRoleDisableOption.ts b/apps/bot/src/lib/set/options/ticketRoleDisableOption.ts new file mode 100644 index 000000000..bdc8d2c75 --- /dev/null +++ b/apps/bot/src/lib/set/options/ticketRoleDisableOption.ts @@ -0,0 +1,9 @@ +import { handleTicketRoleDisable } from '../tickets'; +import type { SetOption } from './types'; + +export const ticketRoleDisableOption: SetOption = { + name: 'ticket-role-disable', + label: 'Ticket Role Disable', + description: 'Remove assigned support ticket manager role', + execute: handleTicketRoleDisable +}; diff --git a/apps/bot/src/lib/set/options/ticketRoleOption.ts b/apps/bot/src/lib/set/options/ticketRoleOption.ts new file mode 100644 index 000000000..b90225c5d --- /dev/null +++ b/apps/bot/src/lib/set/options/ticketRoleOption.ts @@ -0,0 +1,27 @@ +import { container } from '@sapphire/framework'; +import type { Role } from 'discord.js'; +import type { SetOption } from './types'; + +export const ticketRoleOption: SetOption = { + name: 'ticket-role', + label: 'Ticket Role', + description: 'Assign staff/moderator role with ticket management permissions', + execute: async interaction => { + const role = interaction.options.getRole('role') as Role | null; + if (!role) { + return await interaction.editReply({ + content: + ':x: Please provide the `role` option.\n> Example: `/set setting: ticket-role role: @SupportTeam`' + }); + } + + await container.client.session.tickets.setRole({ + guildId: interaction.guildId!, + roleId: role.id + }); + + return await interaction.editReply({ + content: `:white_check_mark: Support ticket manager role set to <@&${role.id}>.` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/ticketToggleOption.ts b/apps/bot/src/lib/set/options/ticketToggleOption.ts new file mode 100644 index 000000000..87f593cd9 --- /dev/null +++ b/apps/bot/src/lib/set/options/ticketToggleOption.ts @@ -0,0 +1,28 @@ +import { container } from '@sapphire/framework'; +import type { SetOption } from './types'; + +export const ticketToggleOption: SetOption = { + name: 'ticket-toggle', + label: 'Ticket Toggle', + description: 'Enable or disable the support ticket system', + execute: async interaction => { + const enabled = interaction.options.getBoolean('enabled'); + if (enabled === null || enabled === undefined) { + return await interaction.editReply({ + content: + ':x: Please provide the `enabled` option (`True` or `False`).\n> Example: `/set setting: ticket-toggle enabled: True`' + }); + } + + await container.client.session.tickets.toggle({ + guildId: interaction.guildId!, + status: enabled + }); + + return await interaction.editReply({ + content: `:white_check_mark: Support ticket system is now **${ + enabled ? 'ENABLED' : 'DISABLED' + }**.` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/ticketTranscriptDisableOption.ts b/apps/bot/src/lib/set/options/ticketTranscriptDisableOption.ts new file mode 100644 index 000000000..5391e9e95 --- /dev/null +++ b/apps/bot/src/lib/set/options/ticketTranscriptDisableOption.ts @@ -0,0 +1,9 @@ +import { handleTicketTranscriptDisable } from '../tickets'; +import type { SetOption } from './types'; + +export const ticketTranscriptDisableOption: SetOption = { + name: 'ticket-transcript-disable', + label: 'Ticket Transcript Disable', + description: 'Disable ticket transcript archiving', + execute: handleTicketTranscriptDisable +}; diff --git a/apps/bot/src/lib/set/options/ticketTranscriptOption.ts b/apps/bot/src/lib/set/options/ticketTranscriptOption.ts new file mode 100644 index 000000000..6114bea43 --- /dev/null +++ b/apps/bot/src/lib/set/options/ticketTranscriptOption.ts @@ -0,0 +1,27 @@ +import { container } from '@sapphire/framework'; +import type { TextChannel } from 'discord.js'; +import type { SetOption } from './types'; + +export const ticketTranscriptOption: SetOption = { + name: 'ticket-transcript', + label: 'Ticket Transcript', + description: 'Set dedicated channel to archive ticket transcripts when closed', + execute: async interaction => { + const channel = interaction.options.getChannel('channel') as TextChannel | null; + if (!channel) { + return await interaction.editReply({ + content: + ':x: Please provide the `channel` option.\n> Example: `/set setting: ticket-transcript channel: #ticket-transcripts`' + }); + } + + await container.client.session.tickets.setTranscriptChannel({ + guildId: interaction.guildId!, + channelId: channel.id + }); + + return await interaction.editReply({ + content: `:white_check_mark: Support ticket transcripts will be archived in <#${channel.id}>.` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/twitchAddOption.ts b/apps/bot/src/lib/set/options/twitchAddOption.ts new file mode 100644 index 000000000..4ae8b9226 --- /dev/null +++ b/apps/bot/src/lib/set/options/twitchAddOption.ts @@ -0,0 +1,9 @@ +import { handleTwitchAdd } from '../twitch'; +import type { SetOption } from './types'; + +export const twitchAddOption: SetOption = { + name: 'twitch-add', + label: 'Twitch Add', + description: 'Add streamer alert to a text or forum channel (if Twitch enabled)', + execute: handleTwitchAdd +}; diff --git a/apps/bot/src/lib/set/options/twitchListOption.ts b/apps/bot/src/lib/set/options/twitchListOption.ts new file mode 100644 index 000000000..a0d180835 --- /dev/null +++ b/apps/bot/src/lib/set/options/twitchListOption.ts @@ -0,0 +1,9 @@ +import { handleTwitchList } from '../twitch'; +import type { SetOption } from './types'; + +export const twitchListOption: SetOption = { + name: 'twitch-list', + label: 'Twitch List', + description: 'List monitored streamers in this server (if Twitch enabled)', + execute: handleTwitchList +}; diff --git a/apps/bot/src/lib/set/options/twitchRemoveOption.ts b/apps/bot/src/lib/set/options/twitchRemoveOption.ts new file mode 100644 index 000000000..e1355197a --- /dev/null +++ b/apps/bot/src/lib/set/options/twitchRemoveOption.ts @@ -0,0 +1,9 @@ +import { handleTwitchRemove } from '../twitch'; +import type { SetOption } from './types'; + +export const twitchRemoveOption: SetOption = { + name: 'twitch-remove', + label: 'Twitch Remove', + description: 'Remove streamer alert from a channel (if Twitch enabled)', + execute: handleTwitchRemove +}; diff --git a/apps/bot/src/lib/set/options/types.ts b/apps/bot/src/lib/set/options/types.ts new file mode 100644 index 000000000..4f5340a8b --- /dev/null +++ b/apps/bot/src/lib/set/options/types.ts @@ -0,0 +1,8 @@ +import type { ChatInputCommandInteraction } from 'discord.js'; + +export interface SetOption { + name: string; + label: string; + description: string; + execute(interaction: ChatInputCommandInteraction): Promise<unknown>; +} diff --git a/apps/bot/src/lib/set/options/viewOption.ts b/apps/bot/src/lib/set/options/viewOption.ts new file mode 100644 index 000000000..a93f87052 --- /dev/null +++ b/apps/bot/src/lib/set/options/viewOption.ts @@ -0,0 +1,9 @@ +import { handleView } from '../view'; +import type { SetOption } from './types'; + +export const viewOption: SetOption = { + name: 'view', + label: 'View Settings', + description: 'View current server settings overview', + execute: handleView +}; diff --git a/apps/bot/src/lib/set/options/welcomeChannelOption.ts b/apps/bot/src/lib/set/options/welcomeChannelOption.ts new file mode 100644 index 000000000..bfbf036fa --- /dev/null +++ b/apps/bot/src/lib/set/options/welcomeChannelOption.ts @@ -0,0 +1,26 @@ +import { container } from '@sapphire/framework'; +import type { SetOption } from './types'; + +export const welcomeChannelOption: SetOption = { + name: 'welcome-channel', + label: 'Welcome Channel', + description: 'Set the text channel for welcome greetings', + execute: async interaction => { + const channel = interaction.options.getChannel('channel'); + if (!channel) { + return await interaction.editReply({ + content: + ':x: Please provide the channel option.\n> Example: /set setting: welcome-channel channel: #welcome' + }); + } + + 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}>.` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/welcomeMessageOption.ts b/apps/bot/src/lib/set/options/welcomeMessageOption.ts new file mode 100644 index 000000000..57b2e566c --- /dev/null +++ b/apps/bot/src/lib/set/options/welcomeMessageOption.ts @@ -0,0 +1,29 @@ +import { container } from '@sapphire/framework'; +import type { SetOption } from './types'; + +export const welcomeMessageOption: SetOption = { + name: 'welcome-message', + label: 'Welcome Message', + description: 'Set custom greeting message (variables: {user}, {server}, {memberCount})', + execute: async interaction => { + const message = + interaction.options.getString('value') ?? + interaction.options.getString('message'); + + if (!message) { + return await interaction.editReply({ + content: + ':x: Please provide the `value` option with your custom welcome message.\n> Example: `/set setting: welcome-message value: Welcome {user} to {server}!`' + }); + } + + 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}` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/welcomeTestOption.ts b/apps/bot/src/lib/set/options/welcomeTestOption.ts new file mode 100644 index 000000000..70d4f81f4 --- /dev/null +++ b/apps/bot/src/lib/set/options/welcomeTestOption.ts @@ -0,0 +1,9 @@ +import { handleWelcomeTest } from '../welcome'; +import type { SetOption } from './types'; + +export const welcomeTestOption: SetOption = { + name: 'welcome-test', + label: 'Welcome Test', + description: 'Send a preview welcome message to verify formatting', + execute: handleWelcomeTest +}; diff --git a/apps/bot/src/lib/set/options/welcomeToggleOption.ts b/apps/bot/src/lib/set/options/welcomeToggleOption.ts new file mode 100644 index 000000000..bb5406ca5 --- /dev/null +++ b/apps/bot/src/lib/set/options/welcomeToggleOption.ts @@ -0,0 +1,28 @@ +import { container } from '@sapphire/framework'; +import type { SetOption } from './types'; + +export const welcomeToggleOption: SetOption = { + name: 'welcome-toggle', + label: 'Welcome Toggle', + description: 'Enable or disable welcome greeting messages', + execute: async interaction => { + const enabled = interaction.options.getBoolean('enabled'); + if (enabled === null || enabled === undefined) { + return await interaction.editReply({ + content: + ':x: Please provide the `enabled` option (`True` or `False`).\n> Example: `/set setting: welcome-toggle 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' + }**.` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/youtubeAddOption.ts b/apps/bot/src/lib/set/options/youtubeAddOption.ts new file mode 100644 index 000000000..1b13150ae --- /dev/null +++ b/apps/bot/src/lib/set/options/youtubeAddOption.ts @@ -0,0 +1,70 @@ +import { container } from '@sapphire/framework'; +import { YouTubeAPI } from '../../youtube/youtubeAPI'; +import type { SetOption } from './types'; +import type { YouTubeAlertType } from '../../session/types'; + +export const youtubeAddOption: SetOption = { + name: 'youtube-add', + label: 'YouTube Add', + description: 'Add YouTube channel alert for streams/uploads (text or forum channel)', + execute: async interaction => { + const { client } = container; + const youtubeInput = + interaction.options.getString('value') ?? + interaction.options.getString('channel') ?? + interaction.options.getString('youtube'); + const targetChannel = + interaction.options.getChannel('channel') ?? + interaction.options.getChannel('discord-channel'); + const alertType = + (interaction.options.getString('alerts') ?? + interaction.options.getString('alert-type') ?? + 'all') as YouTubeAlertType; + + if (!youtubeInput || !targetChannel) { + return await interaction.editReply({ + content: + ':x: Please provide both `value` (YouTube channel handle/URL) and `channel` (Discord channel).\n> Example: `/set setting: youtube-add value: @MrBeast channel: #videos alerts: all`' + }); + } + + const channelInfo = await YouTubeAPI.resolveChannel(youtubeInput); + if (!channelInfo) { + return await interaction.editReply({ + content: `:x: Could not locate a YouTube channel for \`${youtubeInput}\`. Please provide a valid channel URL, handle (e.g. \`@MrBeast\`), or Channel ID (\`UC...\`).` + }); + } + + const latestItems = await YouTubeAPI.getLatestItems(channelInfo.channelId); + const newestVideo = latestItems.find(it => !it.isLive); + const liveVideo = latestItems.find(it => it.isLive); + + const sub = client.session.youtubeConfig.addSubscription({ + channelId: channelInfo.channelId, + channelTitle: channelInfo.title, + logo: channelInfo.thumbnailUrl, + discordChannelId: targetChannel.id, + alertType + }); + + if (newestVideo && !sub.lastVideoId) { + client.session.youtubeConfig.updateStatus({ + channelId: channelInfo.channelId, + lastVideoId: newestVideo.id, + lastStreamId: liveVideo?.id, + isLive: Boolean(liveVideo) + }); + } + + const alertTypeDesc = + alertType === 'all' + ? 'Live Streams & Video Uploads' + : alertType === 'streams' + ? 'Live Streams Only' + : 'Video Uploads Only'; + + return await interaction.editReply({ + content: `:white_check_mark: Successfully subscribed to **${channelInfo.title}**!\nAlerts will be dispatched to <#${targetChannel.id}> (${alertTypeDesc}).` + }); + } +}; diff --git a/apps/bot/src/lib/set/options/youtubeListOption.ts b/apps/bot/src/lib/set/options/youtubeListOption.ts new file mode 100644 index 000000000..57cbca24f --- /dev/null +++ b/apps/bot/src/lib/set/options/youtubeListOption.ts @@ -0,0 +1,9 @@ +import { handleYouTubeList } from '../youtube'; +import type { SetOption } from './types'; + +export const youtubeListOption: SetOption = { + name: 'youtube-list', + label: 'YouTube List', + description: 'List configured YouTube channel alerts', + execute: handleYouTubeList +}; diff --git a/apps/bot/src/lib/set/options/youtubeRemoveOption.ts b/apps/bot/src/lib/set/options/youtubeRemoveOption.ts new file mode 100644 index 000000000..258a03f55 --- /dev/null +++ b/apps/bot/src/lib/set/options/youtubeRemoveOption.ts @@ -0,0 +1,49 @@ +import { container } from '@sapphire/framework'; +import { YouTubeAPI } from '../../youtube/youtubeAPI'; +import type { SetOption } from './types'; + +export const youtubeRemoveOption: SetOption = { + name: 'youtube-remove', + label: 'YouTube Remove', + description: 'Remove YouTube channel alert from a channel', + execute: async interaction => { + const { client } = container; + const youtubeInput = + interaction.options.getString('value') ?? + interaction.options.getString('channel') ?? + interaction.options.getString('youtube'); + const targetChannel = + interaction.options.getChannel('channel') ?? + interaction.options.getChannel('discord-channel'); + + if (!youtubeInput || !targetChannel) { + return await interaction.editReply({ + content: + ':x: Please provide both `value` (YouTube channel handle/URL) and `channel` (Discord channel).\n> Example: `/set setting: youtube-remove value: @MrBeast channel: #videos`' + }); + } + + let channelId = youtubeInput.trim(); + if (!channelId.startsWith('UC') || channelId.length !== 24) { + const resolved = await YouTubeAPI.resolveChannel(youtubeInput); + if (resolved) { + channelId = resolved.channelId; + } + } + + const result = client.session.youtubeConfig.removeSubscription({ + channelId, + discordChannelId: targetChannel.id + }); + + if (!result.success) { + return await interaction.editReply({ + content: `:x: No active alert subscription found for YouTube channel \`${youtubeInput}\` in <#${targetChannel.id}>.` + }); + } + + return await interaction.editReply({ + content: `:white_check_mark: Removed alerts for YouTube channel \`${youtubeInput}\` from <#${targetChannel.id}>.` + }); + } +}; 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<ButtonBuilder>().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..3f73d9cba --- /dev/null +++ b/apps/bot/src/lib/set/twitch.ts @@ -0,0 +1,246 @@ +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('value') ?? + interaction.options.getString('streamer'); + const channelData = interaction.options.getChannel('channel'); + + if (!streamerName || !channelData) { + return await interaction.editReply({ + content: + ':x: Please provide both `value` (streamer name) and `channel` (Discord channel).\n> Example: `/set setting: twitch-add value: shroud channel: #streams`' + }); + } + + 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('value') ?? + interaction.options.getString('streamer'); + const channelData = interaction.options.getChannel('channel'); + + if (!streamerName || !channelData) { + return await interaction.editReply({ + content: + ':x: Please provide both `value` (streamer name) and `channel` (Discord channel).\n> Example: `/set setting: twitch-remove value: shroud channel: #streams`' + }); + } + + 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<unknown>; \ 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 <subcommand> 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/set/youtube.ts b/apps/bot/src/lib/set/youtube.ts new file mode 100644 index 000000000..9c2f77340 --- /dev/null +++ b/apps/bot/src/lib/set/youtube.ts @@ -0,0 +1,143 @@ +import { container } from '@sapphire/framework'; +import { PaginatedFieldMessageEmbed } from '@sapphire/discord.js-utilities'; +import { EmbedBuilder } from 'discord.js'; +import { YouTubeAPI } from '../youtube/youtubeAPI'; +import type { SetHandler } from './types'; +import type { YouTubeAlertType } from '../session/types'; + +export const handleYouTubeAdd: SetHandler = async interaction => { + const { client } = container; + const youtubeInput = interaction.options.getString('youtube', true); + const targetChannel = interaction.options.getChannel('channel', true); + const alertType = (interaction.options.getString('alert-type') ?? 'all') as YouTubeAlertType; + + if (!interaction.deferred && !interaction.replied) { + await interaction.deferReply({ ephemeral: true }); + } + + const channelInfo = await YouTubeAPI.resolveChannel(youtubeInput); + if (!channelInfo) { + return await interaction.editReply({ + content: `:x: Could not locate a YouTube channel for \`${youtubeInput}\`. Please provide a valid channel URL, handle (e.g. \`@MrBeast\`), or Channel ID (\`UC...\`).` + }); + } + + // Fetch latest video immediately to seed lastVideoId and avoid backlog spam + const latestItems = await YouTubeAPI.getLatestItems(channelInfo.channelId); + const newestVideo = latestItems.find(it => !it.isLive); + const liveVideo = latestItems.find(it => it.isLive); + + const sub = client.session.youtubeConfig.addSubscription({ + channelId: channelInfo.channelId, + channelTitle: channelInfo.title, + logo: channelInfo.thumbnailUrl, + discordChannelId: targetChannel.id, + alertType + }); + + // Seed latest video ID if unset + if (newestVideo && !sub.lastVideoId) { + client.session.youtubeConfig.updateStatus({ + channelId: channelInfo.channelId, + lastVideoId: newestVideo.id, + lastStreamId: liveVideo?.id, + isLive: Boolean(liveVideo) + }); + } + + const alertTypeDesc = + alertType === 'all' + ? 'Live Streams & Video Uploads' + : alertType === 'streams' + ? 'Live Streams Only' + : 'Video Uploads Only'; + + return await interaction.editReply({ + content: `:white_check_mark: Successfully subscribed to **${channelInfo.title}**!\nAlerts will be dispatched to <#${targetChannel.id}> (${alertTypeDesc}).` + }); +}; + +export const handleYouTubeRemove: SetHandler = async interaction => { + const { client } = container; + const youtubeInput = interaction.options.getString('youtube', true); + const targetChannel = interaction.options.getChannel('channel', true); + + if (!interaction.deferred && !interaction.replied) { + await interaction.deferReply({ ephemeral: true }); + } + + // Try resolving channel or check direct channelId + const channelInfo = await YouTubeAPI.resolveChannel(youtubeInput); + const channelId = channelInfo ? channelInfo.channelId : youtubeInput.trim(); + + const res = client.session.youtubeConfig.removeSubscription({ + channelId, + discordChannelId: targetChannel.id + }); + + if (!res.success) { + return await interaction.editReply({ + content: `:x: No YouTube alert subscription found for \`${youtubeInput}\` in <#${targetChannel.id}>.` + }); + } + + const title = channelInfo?.title ? `**${channelInfo.title}**` : `channel \`${channelId}\``; + return await interaction.editReply({ + content: `:white_check_mark: Removed alerts for ${title} from <#${targetChannel.id}>.` + }); +}; + +export const handleYouTubeList: SetHandler = async interaction => { + const { client } = container; + const guildId = interaction.guildId!; + + if (!interaction.deferred && !interaction.replied) { + await interaction.deferReply({ ephemeral: true }); + } + + const allSubs = client.session.youtubeConfig.getAllSubscriptions(); + const guildSubs: Array<{ channelTitle: string; discordChannelName: string; alertType: string }> = []; + + for (const sub of allSubs) { + for (const target of sub.channelIds) { + const ch = client.channels.cache.get(target.channelId) as any; + if (ch && ch.guildId === guildId) { + guildSubs.push({ + channelTitle: sub.channelTitle, + discordChannelName: ch.name, + alertType: target.alertType + }); + } + } + } + + if (guildSubs.length === 0) { + return await interaction.editReply({ + content: ':information_source: No YouTube channels configured for alerts in this server. Use `/set youtube-add` to add one!' + }); + } + + const baseEmbed = new EmbedBuilder() + .setColor('#FF0000') + .setTitle('🔴 YouTube Alerts Configuration') + .setAuthor({ + name: `${interaction.guild?.name ?? 'Server'} - YouTube Alerts`, + iconURL: interaction.guild?.iconURL() || undefined + }) + .setFooter({ text: `Total Active Subscriptions: ${guildSubs.length}` }) + .setTimestamp(); + + new PaginatedFieldMessageEmbed<{ channelTitle: string; discordChannelName: string; alertType: string }>() + .setTitleField('Subscriptions') + .setTemplate(baseEmbed) + .setItems(guildSubs) + .formatItems( + item => + `• **${item.channelTitle}** ➔ #${item.discordChannelName} (\`${item.alertType}\`)` + ) + .setItemsPerPage(10) + .make() + .run(interaction); + + return; +}; 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..bd7b5a1ee 100644 --- a/apps/bot/src/lib/structures/ExtendedClient.ts +++ b/apps/bot/src/lib/structures/ExtendedClient.ts @@ -1,9 +1,10 @@ import { SapphireClient } from '@sapphire/framework'; import '@sapphire/plugin-hmr/register'; import { QueueClient } from '../music/classes/QueueClient'; -import Redis from 'ioredis'; +import { prisma, redis } from '@master-bot/db'; +import type { PrismaClient } from '@master-bot/db'; import { - GatewayDispatchEvents, + ForumChannel, IntentsBitField, NewsChannel, TextChannel, @@ -13,10 +14,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<string, TriviaSession> = new Map(); twitch: ClientTwitchExtension = { api: new TwitchAPI( process.env.TWITCH_CLIENT_ID, @@ -42,43 +48,42 @@ export class ExtendedClient extends SapphireClient { ], logger: { level: 100 }, loadMessageCommandListeners: true, - hmr: { - enabled: process.env.NODE_ENV === 'development' - } - }); + ...(process.env.NODE_ENV === 'development' + ? ({ hmr: { enabled: true } } as any) + : {}) + } as any); + + this.prisma = prisma; + 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, + 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) { @@ -115,21 +120,29 @@ export class ExtendedClient extends SapphireClient { this.leaveTimers = {}; } } -export type MessageChannel = TextChannel | ThreadChannel | NewsChannel | null; +export type MessageChannel = + | TextChannel + | ThreadChannel + | NewsChannel + | ForumChannel + | null; declare module '@sapphire/framework' { interface SapphireClient { readonly music: QueueClient; + readonly prisma: PrismaClient; + readonly session: SessionManager; leaveTimers: { [key: string]: NodeJS.Timeout }; + triviaSessions: Map<string, TriviaSession>; 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<string, CommandHelp[]> { + const commands = this.getEnabledCommands(); + const map = new Map<string, CommandHelp[]>(); + + 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/structures/owner.ts b/apps/bot/src/lib/structures/owner.ts new file mode 100644 index 000000000..04793faf2 --- /dev/null +++ b/apps/bot/src/lib/structures/owner.ts @@ -0,0 +1,29 @@ +import type { Client, User } from 'discord.js'; +import Logger from '../logger'; + +/** + * Fetches the Discord Application Owner to restrict sensitive administrative commands. + */ +export async function getApplicationOwnerUser( + client: Client +): Promise<User | null> { + 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/twitch/TwitchEmbed.ts b/apps/bot/src/lib/twitch/TwitchEmbed.ts index 7302a475a..f8580cbe6 100644 --- a/apps/bot/src/lib/twitch/TwitchEmbed.ts +++ b/apps/bot/src/lib/twitch/TwitchEmbed.ts @@ -134,7 +134,7 @@ export class TwitchEmbed { min ) .setTimestamp( - this.change ? Date.now() : Date.parse(this.stream?.started_at!) + this.change ? Date.now() : Date.parse(this.stream?.started_at ?? '') ); return onlineEmbed; diff --git a/apps/bot/src/lib/twitch/notifyChannels.ts b/apps/bot/src/lib/twitch/notifyChannels.ts index 3ad077b36..708ca588c 100644 --- a/apps/bot/src/lib/twitch/notifyChannels.ts +++ b/apps/bot/src/lib/twitch/notifyChannels.ts @@ -2,8 +2,12 @@ import { MessageChannel } from './../structures/ExtendedClient'; 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 { + ChannelType, + ForumChannel, + ThreadChannel, + type Message +} from 'discord.js'; import Logger from '../logger'; // Twitch ids are non changeable, usernames are not good for reference @@ -70,9 +74,11 @@ export async function notify(query: string[]) { // Run through ChannelIds List of Entry/Streamer for (const channelToMsg of client.twitch.notifyList[entry] .sendTo) { - const channel = client.channels.cache.get( - channelToMsg - ) as MessageChannel; + const channel = + (client.channels.cache.get(channelToMsg) as MessageChannel) ?? + ((await client.channels + .fetch(channelToMsg) + .catch(() => null)) as MessageChannel); if (channel) { const twitchMsg = new TwitchEmbed( @@ -83,32 +89,77 @@ export async function notify(query: string[]) { false, // Offline false // Stream Update ); + const embed = await twitchMsg.TwitchEmbed(); + + if (channel.type === ChannelType.GuildForum) { + const forum = channel as ForumChannel; + const postTitle = `🔴 ${stream.user_name} is LIVE: ${stream.title || 'Twitch Stream'}`; + const safeTitle = + postTitle.length > 100 + ? `${postTitle.slice(0, 97)}...` + : postTitle; + + try { + const thread = await forum.threads.create({ + name: safeTitle, + message: { + content: `🔴 **${stream.user_name}** is now live on Twitch!\nhttps://twitch.tv/${stream.user_name}`, + embeds: [embed] + } + }); - await channel - .send({ - embeds: [await twitchMsg.TwitchEmbed()] - }) - .then((message: Message) => { - // Store the channel and Message ID if ( !client.twitch.notifyList[entry].messageHandler[ - message.channel.id + thread.id ] ) client.twitch.notifyList[entry].messageHandler[ - message.channel.id - ] = [message.id]; + thread.id + ] = [thread.id]; else client.twitch.notifyList[entry].messageHandler[ - message.channel.id - ].push(message.id); - }); + thread.id + ].push(thread.id); + } catch (forumErr) { + Logger.error( + `Failed to create forum thread alert for ${stream.user_name}:`, + forumErr + ); + } + } else if ('send' in channel) { + await channel + .send({ + content: `🔴 **${stream.user_name}** is now live on Twitch!\nhttps://twitch.tv/${stream.user_name}`, + embeds: [embed] + }) + .then((message: Message) => { + // Store the channel and Message ID + if ( + !client.twitch.notifyList[entry].messageHandler[ + message.channel.id + ] + ) + client.twitch.notifyList[entry].messageHandler[ + message.channel.id + ] = [message.id]; + else + client.twitch.notifyList[entry].messageHandler[ + message.channel.id + ].push(message.id); + }) + .catch(err => + Logger.error( + `Failed to send stream alert to channel ${channel.id}:`, + err + ) + ); + } } } client.twitch.notifyList[entry].messageSent = true; // Update DataBase - await trpcNode.twitch.updateNotificationStatus.mutate({ + client.session.twitchConfig.updateNotificationStatus({ userId: entry, sent: true, live: true @@ -135,24 +186,49 @@ export async function notify(query: string[]) { await client.channels .fetch(channelId) .then(async channel => { - const msgChannel = channel as MessageChannel; - - for (let messageId in client.twitch.notifyList[entry] + for (const messageId in client.twitch.notifyList[entry] .messageHandler![channelId]) { - await msgChannel?.messages - .edit( - client.twitch.notifyList[entry].messageHandler[ - channelId - ][messageId], - { + const targetMsgId = + client.twitch.notifyList[entry].messageHandler![ + channelId + ][messageId]; + + if (channel?.isThread()) { + const thread = channel as ThreadChannel; + const starter = + (await thread.messages + .fetch(targetMsgId) + .catch(() => null)) || + (await thread + .fetchStarterMessage() + .catch(() => null)); + if (starter) { + await starter + .edit({ + embeds: [await twitchMsg.TwitchEmbed()] + }) + .catch(error => + Logger.error( + 'Failed to Edit Stream Notification ' + + error + ) + ); + } + } else if ( + channel?.isTextBased() && + 'messages' in channel + ) { + await (channel as any).messages + .edit(targetMsgId, { embeds: [await twitchMsg.TwitchEmbed()] - } - ) - .catch(error => - Logger.error( - 'Failed to Edit Stream Notification ' + error - ) - ); + }) + .catch((error: unknown) => + Logger.error( + 'Failed to Edit Stream Notification ' + + error + ) + ); + } } }); } @@ -181,30 +257,56 @@ export async function notify(query: string[]) { for (const channelId in client.twitch.notifyList[entry] .messageHandler) { await client.channels.fetch(channelId).then(async channel => { - const msgChannel = channel as MessageChannel; for (const messageId in client.twitch.notifyList[entry] .messageHandler[channelId]) { - await msgChannel?.messages - .edit( - client.twitch.notifyList[entry].messageHandler[ - channelId - ][messageId], - { + const targetMsgId = + client.twitch.notifyList[entry].messageHandler[channelId][ + messageId + ]; + + if (channel?.isThread()) { + const thread = channel as ThreadChannel; + const starter = + (await thread.messages + .fetch(targetMsgId) + .catch(() => null)) || + (await thread + .fetchStarterMessage() + .catch(() => null)); + if (starter) { + await starter + .edit({ + embeds: [await twitchMsg.TwitchEmbed()] + }) + .catch(error => + Logger.error( + 'Failed to Edit Offline Stream Notification ' + + error + ) + ); + } + } else if ( + channel?.isTextBased() && + 'messages' in channel + ) { + await (channel as any).messages + .edit(targetMsgId, { embeds: [await twitchMsg.TwitchEmbed()] - } - ) - .catch(error => - Logger.error( - 'Failed to Edit Offline Stream Notification ' + error - ) - ); + }) + .catch((error: unknown) => + Logger.error( + 'Failed to Edit Offline Stream Notification ' + + error + ) + ); + } } }); } 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 +334,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/lib/youtube/notifyYouTube.ts b/apps/bot/src/lib/youtube/notifyYouTube.ts new file mode 100644 index 000000000..de39bfc4c --- /dev/null +++ b/apps/bot/src/lib/youtube/notifyYouTube.ts @@ -0,0 +1,207 @@ +import type { SapphireClient } from '@sapphire/framework'; +import { + ChannelType, + ForumChannel, + type TextChannel +} from 'discord.js'; +import { YouTubeAPI } from './youtubeAPI'; +import { + createYouTubeLiveEmbed, + createYouTubeUploadEmbed +} from '../embeds/commands/other/youtubeEmbed'; +import Logger from '../logger'; + +let isRunning = false; +let intervalTimer: NodeJS.Timeout | null = null; + +export async function checkYouTubeChannels(client: SapphireClient): Promise<void> { + if (isRunning) return; + isRunning = true; + + try { + const subscriptions = client.session.youtubeConfig.getAllSubscriptions(); + if (subscriptions.length === 0) { + isRunning = false; + return; + } + + for (const sub of subscriptions) { + const items = await YouTubeAPI.getLatestItems(sub.channelId); + if (items.length === 0) continue; + + const newestItem = items[0]; + const liveItem = items.find(it => it.isLive); + + // 1. Check Live Streams + if (liveItem) { + if (sub.lastStreamId !== liveItem.id) { + // New Live Stream! + const embed = createYouTubeLiveEmbed({ + channelTitle: liveItem.channelTitle, + channelUrl: `https://www.youtube.com/channel/${sub.channelId}`, + channelLogo: sub.logo, + streamTitle: liveItem.title, + streamUrl: liveItem.url, + videoId: liveItem.id, + description: liveItem.description, + startedAt: liveItem.publishedAt, + thumbnailUrl: liveItem.thumbnailUrl + }); + + for (const target of sub.channelIds) { + if (target.alertType === 'uploads') continue; // Only wants uploads + + try { + const channel = + client.channels.cache.get(target.channelId) ?? + (await client.channels.fetch(target.channelId).catch(() => null)); + + if (!channel) continue; + + if (channel.type === ChannelType.GuildForum) { + const forum = channel as ForumChannel; + const threadTitle = `🔴 ${liveItem.channelTitle} is LIVE: ${liveItem.title}`; + const safeTitle = + threadTitle.length > 100 + ? `${threadTitle.slice(0, 97)}...` + : threadTitle; + + await forum.threads.create({ + name: safeTitle, + message: { + content: `🔴 **${liveItem.channelTitle}** is now live on YouTube!\n${liveItem.url}`, + embeds: [embed] + } + }); + } else if ('send' in channel) { + await (channel as TextChannel).send({ + content: `🔴 **${liveItem.channelTitle}** is now live on YouTube!\n${liveItem.url}`, + embeds: [embed] + }); + } + } catch (err) { + Logger.error( + `[YouTubeNotify] Error sending live alert to channel ${target.channelId}:`, + err + ); + } + } + + client.session.youtubeConfig.updateStatus({ + channelId: sub.channelId, + lastStreamId: liveItem.id, + isLive: true + }); + } + } else if (sub.isLive) { + // Stream ended + client.session.youtubeConfig.updateStatus({ + channelId: sub.channelId, + isLive: false + }); + } + + // 2. Check Video Uploads + if (newestItem && !newestItem.isLive) { + if (!sub.lastVideoId) { + // Seed initial lastVideoId without spamming history + client.session.youtubeConfig.updateStatus({ + channelId: sub.channelId, + lastVideoId: newestItem.id, + isLive: sub.isLive + }); + } else if (sub.lastVideoId !== newestItem.id) { + // New Upload Detected! + const embed = createYouTubeUploadEmbed({ + channelTitle: newestItem.channelTitle, + channelUrl: `https://www.youtube.com/channel/${sub.channelId}`, + channelLogo: sub.logo, + videoTitle: newestItem.title, + videoUrl: newestItem.url, + videoId: newestItem.id, + description: newestItem.description, + publishedAt: newestItem.publishedAt, + thumbnailUrl: newestItem.thumbnailUrl + }); + + for (const target of sub.channelIds) { + if (target.alertType === 'streams') continue; // Only wants streams + + try { + const channel = + client.channels.cache.get(target.channelId) ?? + (await client.channels.fetch(target.channelId).catch(() => null)); + + if (!channel) continue; + + if (channel.type === ChannelType.GuildForum) { + const forum = channel as ForumChannel; + const threadTitle = `🎬 ${newestItem.channelTitle}: ${newestItem.title}`; + const safeTitle = + threadTitle.length > 100 + ? `${threadTitle.slice(0, 97)}...` + : threadTitle; + + await forum.threads.create({ + name: safeTitle, + message: { + content: `🎬 **${newestItem.channelTitle}** uploaded a new video!\n${newestItem.url}`, + embeds: [embed] + } + }); + } else if ('send' in channel) { + await (channel as TextChannel).send({ + content: `🎬 **${newestItem.channelTitle}** uploaded a new video!\n${newestItem.url}`, + embeds: [embed] + }); + } + } catch (err) { + Logger.error( + `[YouTubeNotify] Error sending upload alert to channel ${target.channelId}:`, + err + ); + } + } + + client.session.youtubeConfig.updateStatus({ + channelId: sub.channelId, + lastVideoId: newestItem.id, + isLive: sub.isLive + }); + } + } + } + } catch (err) { + Logger.error('[YouTubeNotify] Unexpected error in monitor loop:', err); + } finally { + isRunning = false; + } +} + +export function startYouTubeMonitor( + client: SapphireClient, + intervalMs = 60000 +): void { + if (intervalTimer) { + clearInterval(intervalTimer); + } + // Initial check delayed by 10s after startup + setTimeout(() => { + void checkYouTubeChannels(client); + }, 10000); + + intervalTimer = setInterval(() => { + void checkYouTubeChannels(client); + }, intervalMs); + + Logger.info( + `[YouTubeNotify] YouTube stream & upload alert monitor initialized (${Math.round(intervalMs / 1000)}s interval).` + ); +} + +export function stopYouTubeMonitor(): void { + if (intervalTimer) { + clearInterval(intervalTimer); + intervalTimer = null; + } +} diff --git a/apps/bot/src/lib/youtube/youtubeAPI.ts b/apps/bot/src/lib/youtube/youtubeAPI.ts new file mode 100644 index 000000000..001d4f5a2 --- /dev/null +++ b/apps/bot/src/lib/youtube/youtubeAPI.ts @@ -0,0 +1,310 @@ +import Logger from '../logger'; + +export interface YouTubeChannelInfo { + channelId: string; + title: string; + description?: string; + customUrl?: string; + thumbnailUrl?: string; +} + +export interface YouTubeVideoItem { + id: string; + title: string; + url: string; + channelId: string; + channelTitle: string; + description: string; + publishedAt: string; + thumbnailUrl: string; + isLive: boolean; +} + +export class YouTubeAPI { + public static get clientId(): string | undefined { + return process.env.YOUTUBE_CLIENT_ID; + } + + public static get clientSecret(): string | undefined { + return process.env.YOUTUBE_CLIENT_SECRET; + } + + public static get refreshToken(): string | undefined { + return process.env.YOUTUBE_REFRESH_TOKEN; + } + + private static cachedAccessToken: { token: string; expiresAt: number } | null = null; + + public static async getAccessToken(): Promise<string | null> { + if (this.cachedAccessToken && Date.now() < this.cachedAccessToken.expiresAt) { + return this.cachedAccessToken.token; + } + + const clientId = this.clientId; + const clientSecret = this.clientSecret; + const refreshToken = this.refreshToken; + + if (!clientId || !clientSecret || !refreshToken) { + return null; + } + + try { + const res = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token' + }) + }); + + if (res.ok) { + const data = (await res.json()) as any; + if (data.access_token) { + this.cachedAccessToken = { + token: data.access_token, + expiresAt: Date.now() + (data.expires_in ? (data.expires_in - 60) * 1000 : 3500 * 1000) + }; + return data.access_token; + } + } + } catch (err) { + Logger.warn(`[YouTubeAPI] Error obtaining OAuth access token: ${err}`); + } + return null; + } + + private static async getAuthHeadersAndUrl( + baseUrl: string + ): Promise<{ url: string; headers: Record<string, string> } | null> { + const accessToken = await this.getAccessToken(); + if (accessToken) { + return { + url: baseUrl, + headers: { Authorization: `Bearer ${accessToken}` } + }; + } + return null; + } + + /** + * Resolves any user-provided channel string (handle `@name`, channel ID `UC...`, + * full channel URL, or vanity URL) into a standardized Channel ID and metadata. + */ + public static async resolveChannel( + input: string + ): Promise<YouTubeChannelInfo | null> { + const trimmed = input.trim(); + + // Check if direct channel ID (starts with UC and is 24 chars) + if (/^UC[a-zA-Z0-9_-]{22}$/.test(trimmed)) { + return await this.fetchChannelById(trimmed); + } + + // Extract handle if format is @handle or youtube.com/@handle + const handleMatch = trimmed.match(/(?:https?:\/\/(?:www\.)?youtube\.com\/)?@([a-zA-Z0-9_.-]+)/i); + if (handleMatch) { + const handle = handleMatch[1]; + return await this.fetchChannelByHandle(handle); + } + + // Extract channel ID from URL if youtube.com/channel/UC... + const urlMatch = trimmed.match(/(?:https?:\/\/(?:www\.)?youtube\.com\/channel\/)(UC[a-zA-Z0-9_-]{22})/i); + if (urlMatch) { + return await this.fetchChannelById(urlMatch[1]); + } + + // Try handle lookup without @ + return await this.fetchChannelByHandle(trimmed); + } + + public static async fetchChannelById( + channelId: string + ): Promise<YouTubeChannelInfo | null> { + const auth = await this.getAuthHeadersAndUrl( + `https://www.googleapis.com/youtube/v3/channels?part=snippet&id=${channelId}` + ); + if (auth) { + try { + const res = await fetch(auth.url, { headers: auth.headers }); + if (res.ok) { + const data = (await res.json()) as any; + if (data.items && data.items.length > 0) { + const item = data.items[0]; + return { + channelId: item.id, + title: item.snippet.title, + description: item.snippet.description, + customUrl: item.snippet.customUrl, + thumbnailUrl: item.snippet.thumbnails?.default?.url + }; + } + } + } catch (err) { + Logger.warn(`[YouTubeAPI] Error fetching channel by ID: ${err}`); + } + } + + // Fallback to RSS feed + return await this.fetchChannelFromRss(channelId); + } + + public static async fetchChannelByHandle( + handle: string + ): Promise<YouTubeChannelInfo | null> { + const cleanHandle = handle.startsWith('@') ? handle : `@${handle}`; + const auth = await this.getAuthHeadersAndUrl( + `https://www.googleapis.com/youtube/v3/channels?part=snippet&forHandle=${encodeURIComponent(cleanHandle)}` + ); + + if (auth) { + try { + const res = await fetch(auth.url, { headers: auth.headers }); + if (res.ok) { + const data = (await res.json()) as any; + if (data.items && data.items.length > 0) { + const item = data.items[0]; + return { + channelId: item.id, + title: item.snippet.title, + description: item.snippet.description, + customUrl: item.snippet.customUrl, + thumbnailUrl: item.snippet.thumbnails?.default?.url + }; + } + } + } catch (err) { + Logger.warn(`[YouTubeAPI] Error fetching channel by handle: ${err}`); + } + } + + // Web scraper fallback for handle resolution + try { + const res = await fetch(`https://www.youtube.com/${cleanHandle}`, { + headers: { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + } + }); + if (res.ok) { + const html = await res.text(); + // Extract channelId from canonical link or meta tag + const match = + html.match(/<meta itemprop="channelId" content="(UC[a-zA-Z0-9_-]{22})"/i) || + html.match(/<link rel="canonical" href="https:\/\/www\.youtube\.com\/channel\/(UC[a-zA-Z0-9_-]{22})"/i) || + html.match(/"browseId":"(UC[a-zA-Z0-9_-]{22})"/i); + + if (match && match[1]) { + return await this.fetchChannelById(match[1]); + } + } + } catch (err) { + Logger.warn(`[YouTubeAPI] Fallback handle lookup failed: ${err}`); + } + + return null; + } + + private static async fetchChannelFromRss( + channelId: string + ): Promise<YouTubeChannelInfo | null> { + try { + const res = await fetch( + `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}` + ); + if (!res.ok) return null; + const xml = await res.text(); + + const titleMatch = xml.match(/<title>([^<]+)<\/title>/i); + const title = titleMatch ? titleMatch[1] : channelId; + + return { + channelId, + title, + customUrl: `https://www.youtube.com/channel/${channelId}` + }; + } catch { + return null; + } + } + + /** + * Fetches the latest videos and live streams for a channel. + * Uses RSS parsing for high speed and zero quota, plus API enrichment when available. + */ + public static async getLatestItems( + channelId: string + ): Promise<YouTubeVideoItem[]> { + const items: YouTubeVideoItem[] = []; + + try { + const res = await fetch( + `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}` + ); + if (!res.ok) return []; + const xml = await res.text(); + + // Parse XML entries + const entries = xml.split('<entry>'); + for (let i = 1; i < Math.min(entries.length, 6); i++) { + const entry = entries[i]; + const idMatch = entry.match(/<yt:videoId>([^<]+)<\/yt:videoId>/i); + const titleMatch = entry.match(/<title>([^<]+)<\/title>/i); + const channelTitleMatch = entry.match(/<name>([^<]+)<\/name>/i); + const publishedMatch = entry.match(/<published>([^<]+)<\/published>/i); + const descMatch = entry.match(/<media:description>([\s\S]*?)<\/media:description>/i); + const thumbMatch = entry.match(/<media:thumbnail[^>]+url="([^"]+)"/i); + + if (idMatch && titleMatch) { + const videoId = idMatch[1]; + items.push({ + id: videoId, + title: titleMatch[1], + url: `https://www.youtube.com/watch?v=${videoId}`, + channelId, + channelTitle: channelTitleMatch ? channelTitleMatch[1] : 'YouTube Channel', + description: descMatch ? descMatch[1].trim() : '', + publishedAt: publishedMatch ? publishedMatch[1] : new Date().toISOString(), + thumbnailUrl: thumbMatch + ? thumbMatch[1] + : `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`, + isLive: false + }); + } + } + } catch (err) { + Logger.error(`[YouTubeAPI] Error fetching RSS feed for ${channelId}:`, err); + } + + // If OAuth token or API key is available, check video details for live broadcast status + if (items.length > 0) { + const ids = items.map(it => it.id).join(','); + const auth = await this.getAuthHeadersAndUrl( + `https://www.googleapis.com/youtube/v3/videos?part=snippet,liveStreamingDetails&id=${ids}` + ); + if (auth) { + try { + const res = await fetch(auth.url, { headers: auth.headers }); + if (res.ok) { + const data = (await res.json()) as any; + if (data.items) { + for (const vid of data.items) { + const matched = items.find(it => it.id === vid.id); + if (matched) { + const broadcast = vid.snippet?.liveBroadcastContent; + matched.isLive = broadcast === 'live'; + } + } + } + } + } catch (err) { + Logger.warn(`[YouTubeAPI] Error enriching live status via API: ${err}`); + } + } + } + + return items; + } +} 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<void> { - 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<ListenerOptions>({ name: 'guildCreate' @@ -10,15 +9,17 @@ export class GuildCreateListener extends Listener { public override async run(guild: Guild): Promise<void> { 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<ListenerOptions>({ name: 'guildDelete' }) export class GuildDeleteListener extends Listener { public override async run(guild: Guild): Promise<void> { - 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<ListenerOptions>({ name: 'guildMemberAdd' }) export class GuildMemberListener extends Listener { public override async run(member: GuildMember): Promise<void> { - 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<ListenerOptions>({ + name: 'guildMemberRemove' +}) +export class GuildMemberRemoveListener extends Listener { + public override async run(member: GuildMember): Promise<void> { + 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..820222834 --- /dev/null +++ b/apps/bot/src/listeners/interaction/ticketButtonListener.ts @@ -0,0 +1,280 @@ +import { ApplyOptions } from '@sapphire/decorators'; +import { Events, Listener, type ListenerOptions } from '@sapphire/framework'; +import { + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ChannelType, + TextChannel, + ThreadAutoArchiveDuration, + ThreadChannel +} from 'discord.js'; +import type { Interaction } from 'discord.js'; +import { + createTicketCreatedEmbed, + createTicketTranscriptEmbed, + createTicketClosedEmbed +} from '../../lib/embeds/events/tickets/ticketEmbed'; + +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<ListenerOptions>({ + event: Events.InteractionCreate +}) +export class TicketButtonListener extends Listener { + public override async run(interaction: Interaction): Promise<void> { + 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 = createTicketCreatedEmbed({ + user, + guild, + thread, + message: formattedMessage, + ticketRoleId + }); + + const closeButton = new ButtonBuilder() + .setCustomId('ticket_close') + .setLabel('Close Ticket') + .setStyle(ButtonStyle.Danger) + .setEmoji('🔒'); + + const actionRow = new ActionRowBuilder<ButtonBuilder>().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 = createTicketTranscriptEmbed({ + thread, + closedBy: interaction.user + }); + + await transcriptChannel.send({ + embeds: [transcriptEmbed], + files: [attachment] + }); + } + } catch (transcriptError) { + this.container.logger.error( + 'Failed to send ticket transcript:', + transcriptError + ); + } + } + + const closeEmbed = createTicketClosedEmbed({ + thread, + closedBy: interaction.user + }); + + 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..579db97d5 100644 --- a/apps/bot/src/listeners/music/musicSongPlayMessage.ts +++ b/apps/bot/src/listeners/music/musicSongPlayMessage.ts @@ -16,16 +16,16 @@ 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 ); try { await manageStageChannel( - channel.guild.members.me?.voice.channel!, + channel.guild.members.me?.voice?.channel as any, channel.guild.members.me!, queue ); 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<void> { - 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<ListenerOptions>({ @@ -12,7 +11,7 @@ export class VoiceStateUpdateListener extends Listener { oldState: VoiceState, newState: VoiceState ): Promise<void> { - 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<string, DisabledCacheEntry>(); + +/** + * 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<PreconditionOptions>({ 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<PreconditionOptions>({ 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<PreconditionOptions>({ 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<PreconditionOptions>({ @@ -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<AppRouter>({ - links: [ - httpBatchLink({ - url: 'http://localhost:3000/api/trpc' - }) - ], - transformer: superjson -}); diff --git a/apps/bot/tsconfig.json b/apps/bot/tsconfig.json index b08cfc082..e7adc5c71 100644 --- a/apps/bot/tsconfig.json +++ b/apps/bot/tsconfig.json @@ -1,20 +1,30 @@ { - "extends": "@sapphire/ts-config", "compilerOptions": { "rootDir": "src", + "outDir": "dist", + "module": "commonjs", + "moduleResolution": "node", + "target": "ES2022", + "lib": ["esnext"], + "noEmit": false, + "declaration": false, + "sourceMap": true, "experimentalDecorators": true, "incremental": true, - "outDir": "dist", - "strict": true, "tsBuildInfoFile": "dist/.tsbuildinfo", "resolveJsonModule": true, "noUnusedParameters": false, + "noUnusedLocals": false, "skipLibCheck": true, "allowSyntheticDefaultImports": true, "verbatimModuleSyntax": false, "esModuleInterop": true, - "noImplicitAny": false + "noImplicitAny": false, + "strict": false, + "noUncheckedIndexedAccess": false }, - "include": ["src", "scripts", "src/env.ts"], - "exclude": ["node_modules"] + "include": ["src", "src/env.ts"], + "exclude": ["node_modules", "dist"] } + + 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..de43be386 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](../../../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 @@ /// <reference types="next/image-types/global" /> // 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..1645c3a43 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -2,65 +2,57 @@ "name": "@master-bot/dashboard", "version": "0.1.0", "private": true, + "license": "MIT", "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 ( - <div> - <h1 className="text-3xl font-semibold mb-4"> - Enable / Disable Commands Panel - </h1> - {commands ? ( - <div className="flex flex-col gap-4"> - {commands.map(command => { - const isCommandEnabled = !guild?.disabledCommands.includes( - command.id - ); + <div className="space-y-8 max-w-6xl"> + <div> + <h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3"> + <SlidersHorizontal className="h-8 w-8 text-indigo-500" /> + Command Management Panel + </h1> + <p className="text-sm text-slate-600 dark:text-slate-400 mt-1"> + Enable or disable slash commands for this server and configure custom + role permissions. + </p> + </div> + + {rawCommands && rawCommands.length > 0 && activeCategories.length > 0 ? ( + <div className="space-y-8"> + {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 ( <div - key={command.id} - className={`${ - isCommandEnabled - ? 'dark:bg-slate-700 bg-slate-400' - : 'dark:bg-slate-800 bg-slate-500' - } border-b flex justify-between items-center dark:border-slate-400 border-slate-700 px-2 py-1`} + key={category.id} + className="bg-white dark:bg-slate-900/90 border border-slate-200 dark:border-slate-800 rounded-xl overflow-hidden shadow-sm" > - <div className="flex flex-col gap-1"> - <Link - href={`/dashboard/${params.server_id}/commands/${command.id}`} - > - <h3 className="text-lg">{command.name}</h3> - </Link> - <p className="text-sm">{command.description}</p> + {/* Category Header */} + <div className="p-5 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50 flex flex-col md:flex-row md:items-center justify-between gap-3"> + <div className="flex items-center gap-3"> + <div className="p-2 bg-indigo-500/10 text-indigo-500 rounded-lg"> + <category.icon className="h-5 w-5" /> + </div> + <div> + <h2 className="text-lg font-bold text-slate-900 dark:text-white"> + {category.title} + </h2> + <p className="text-xs text-slate-500 dark:text-slate-400"> + {category.description} + </p> + </div> + </div> + + <div className="flex items-center gap-2"> + <span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300"> + {categoryCommands.length} commands + </span> + </div> </div> - <div> - <CommandToggleSwitch - commandEnabled={isCommandEnabled} - serverId={params.server_id} - commandId={command.id} - /> + + {/* Category Command List */} + <div className="divide-y divide-slate-100 dark:divide-slate-800/60"> + {categoryCommands.map(command => { + const isServerDisabled = + disabledCommands.includes(command.id) ?? false; + const isCommandEnabled = !isServerDisabled; + + return ( + <div + key={command.id} + className="p-4 flex items-center justify-between gap-4 hover:bg-slate-50 dark:hover:bg-slate-800/30 transition-colors" + > + <div className="flex-1 min-w-0"> + <div className="flex items-center gap-2.5"> + <Link + href={`/dashboard/${server_id}/commands/${command.id}`} + className="font-semibold text-slate-900 dark:text-white hover:text-indigo-500 transition-colors text-base" + > + /{command.name} + </Link> + + {/* Status Badge */} + {isServerDisabled ? ( + <span className="text-[10px] font-medium px-2 py-0.5 rounded bg-rose-500/15 text-rose-600 dark:text-rose-400 border border-rose-500/20"> + Disabled (Guild) + </span> + ) : ( + <span className="text-[10px] font-medium px-2 py-0.5 rounded bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20"> + Active + </span> + )} + </div> + + <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1"> + {command.description || 'No description available'} + </p> + </div> + + <div> + <CommandToggleSwitch + commandEnabled={isCommandEnabled} + serverId={server_id} + commandId={command.id} + /> + </div> + </div> + ); + })} </div> </div> ); })} </div> ) : ( - <div className="text-red-500">Error loading commands</div> + <div className="p-8 text-center bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl"> + <Info className="h-10 w-10 text-slate-400 mx-auto mb-3" /> + <h3 className="text-lg font-semibold text-slate-800 dark:text-slate-200"> + No Active Commands Available + </h3> + <p className="text-sm text-slate-500 mt-1"> + All command categories are currently disabled by global + configuration or no commands are registered. + </p> + </div> )} </div> ); 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 ( + <div className="flex items-center gap-2"> + <Switch + checked={false} + disabled={true} + aria-label={ + disabledReason ?? 'Globally disabled via environment configuration' + } + /> + </div> + ); + } + return ( <Switch checked={commandEnabled} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/layout.tsx b/apps/dashboard/src/app/dashboard/[server_id]/layout.tsx index e1af2652f..5ac3c56a2 100644 --- a/apps/dashboard/src/app/dashboard/[server_id]/layout.tsx +++ b/apps/dashboard/src/app/dashboard/[server_id]/layout.tsx @@ -8,19 +8,19 @@ export default async function Layout({ params, children }: { - params: { server_id: string }; + params: Promise<{ server_id: string }>; 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 } }); @@ -30,14 +30,14 @@ export default async function Layout({ return ( <div className="flex h-screen"> - <section className="border-r border-slate-600 px-6 py-4"> - <Sidebar server_id={params.server_id} /> + <section className="border-r border-border px-6 py-4"> + <Sidebar server_id={server_id} /> </section> <section className="flex-1 flex flex-col"> - <header className="flex justify-end px-6 py-4"> + <header className="flex justify-end px-6 py-4 border-b border-border/40"> <HeaderButtons /> </header> - <main className="dark:bg-slate-800 bg-slate-300 flex-1 p-6 overflow-auto"> + <main className="bg-background flex-1 p-6 overflow-auto"> {children} </main> </section> 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<string[]>( + 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 ( + <div className="flex flex-col gap-6"> + {/* Top action bar */} + <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 p-4 rounded-xl border border-gray-800 bg-gray-900/60"> + <div> + <h4 className="text-base font-semibold text-white"> + 📊 Active Log Triggers: {selectedEvents.length} /{' '} + {ALL_EVENT_IDS.length} + </h4> + <p className="text-xs text-gray-400"> + Select which specific Discord server events are dispatched to your + log channel. + </p> + </div> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + size="sm" + className="text-xs border-gray-700" + onClick={handleEnableAllOverall} + > + Enable All + </Button> + <Button + type="button" + variant="outline" + size="sm" + className="text-xs border-gray-700" + onClick={handleDisableAllOverall} + > + Disable All + </Button> + <Button + type="button" + size="sm" + disabled={isSaving} + onClick={handleSave} + className="bg-indigo-600 hover:bg-indigo-500 text-white text-xs" + > + {isSaving ? 'Saving...' : 'Save Changes'} + </Button> + </div> + </div> + + {/* Category Cards */} + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + {LOG_CATEGORIES.map(category => { + const activeCount = category.events.filter(e => + selectedEvents.includes(e.id) + ).length; + const allActive = activeCount === category.events.length; + + return ( + <div + key={category.name} + className="flex flex-col rounded-xl border border-gray-800 bg-gray-900/40 p-5 shadow-sm" + > + <div className="flex items-center justify-between border-b border-gray-800/80 pb-3 mb-4"> + <div className="flex items-center gap-2.5"> + <span className="text-xl">{category.icon}</span> + <div> + <h5 className="text-sm font-semibold text-white"> + {category.name} + </h5> + <p className="text-xs text-gray-400"> + {category.description} + </p> + </div> + </div> + <div className="flex items-center gap-2"> + <span className="text-xs font-mono text-gray-400 bg-black/40 px-2 py-0.5 rounded border border-gray-800"> + {activeCount}/{category.events.length} + </span> + <button + type="button" + onClick={() => handleToggleCategory(category, !allActive)} + className="text-xs text-blue-400 hover:underline" + > + {allActive ? 'Disable all' : 'Enable all'} + </button> + </div> + </div> + + <div className="flex flex-col gap-3.5 flex-1"> + {category.events.map(event => { + const isChecked = selectedEvents.includes(event.id); + return ( + <div + key={event.id} + className="flex items-start justify-between gap-3 p-2.5 rounded-lg bg-black/30 border border-gray-800/50 hover:border-gray-700/80 transition-colors" + > + <div className="flex-1 pr-2"> + <label + htmlFor={event.id} + className="text-xs font-medium text-gray-200 cursor-pointer block" + > + {event.label} + </label> + <p className="text-[11px] text-gray-400 mt-0.5 leading-relaxed"> + {event.description} + </p> + </div> + <Switch + id={event.id} + checked={isChecked} + onCheckedChange={() => handleToggleEvent(event.id)} + /> + </div> + ); + })} + </div> + </div> + ); + })} + </div> + + {/* Floating Bottom Action Bar */} + <div className="sticky bottom-4 z-10 flex items-center justify-between p-4 rounded-xl border border-indigo-900/60 bg-gray-950/95 backdrop-blur shadow-2xl"> + <span className="text-xs text-gray-300"> + Remember to save your settings after making changes. + </span> + <Button + type="button" + disabled={isSaving} + onClick={handleSave} + className="bg-indigo-600 hover:bg-indigo-500 text-white font-medium" + > + {isSaving ? 'Saving...' : 'Save Log Settings'} + </Button> + </div> + </div> + ); +} 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 <div>Error loading guild</div>; + } + + return ( + <> + <div className="flex items-center gap-4 mb-2"> + <Link + href={`/dashboard/${server_id}`} + className="text-sm text-gray-400 hover:text-white transition-colors" + > + ← Back to Server + </Link> + </div> + + <h1 className="text-3xl font-semibold">Audit & Moderation Logging</h1> + <div className="ml-2 mt-6 flex flex-col gap-6 max-w-5xl"> + <div className="flex flex-col gap-2"> + <h3 className="text-lg text-gray-300"> + Track server events, moderation actions, and audit updates + </h3> + <div className="flex items-center gap-4"> + <span className="text-sm text-gray-400">System Status:</span> + {guild.logChannelEnabled && guild.logChannel ? ( + <span className="text-sm font-semibold text-green-400 bg-green-950/50 px-2.5 py-1 rounded-full border border-green-800/40"> + 🟢 Enabled + </span> + ) : ( + <span className="text-sm font-semibold text-red-400 bg-red-950/50 px-2.5 py-1 rounded-full border border-red-800/40"> + 🔴 Disabled + </span> + )} + <LogChannelToggle + logChannelEnabled={Boolean(guild.logChannelEnabled)} + serverId={server_id} + /> + </div> + </div> + + <div className="rounded-xl border border-gray-800 bg-gray-900/40 p-5 shadow-sm flex flex-col gap-4"> + <LogChannelSet + guildId={server_id} + initialChannel={guild.logChannel} + /> + </div> + + {guild.logChannelEnabled && ( + <LogEventsForm + guildId={server_id} + initialEvents={JSON.parse(guild.logEvents || '[]')} + /> + )} + </div> + </> + ); +} 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 ( + <div className="flex flex-col gap-4"> + <div> + <h4 className="text-lg font-medium text-white mb-1"> + 📢 Target Log Channel + </h4> + <p className="text-sm text-gray-400"> + Select the text channel where audit events, moderation actions, and + server logs will be dispatched. + </p> + </div> + + {isLoading && !data ? ( + <div className="text-gray-400 text-sm">Loading channels...</div> + ) : ( + <div className="flex flex-col sm:flex-row items-start sm:items-center gap-3"> + <Select onValueChange={setValue} defaultValue={value}> + <SelectTrigger className="w-64 bg-black/60 border-gray-700 text-white"> + <SelectValue placeholder="Select a text channel" /> + </SelectTrigger> + <SelectContent className="bg-slate-900 border-gray-700 text-white"> + {data?.channels.map(channel => ( + <SelectItem key={channel.id} value={channel.id}> + #{channel.name} + </SelectItem> + ))} + </SelectContent> + </Select> + + <Button + type="button" + disabled={!value || isPending} + onClick={() => { + if (!value) return; + mutate( + { + guildId, + channelId: value + }, + { + onSuccess: () => { + toast({ + title: 'Audit log channel updated', + description: + 'Server event logs will now be sent to this channel.' + }); + }, + onError: () => { + toast({ + title: 'Error setting log channel', + description: 'Please try again later.', + variant: 'destructive' + }); + } + } + ); + }} + > + {isPending ? 'Saving...' : 'Save Log Channel'} + </Button> + </div> + )} + </div> + ); +} 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 ( + <div className="flex items-center space-x-2"> + <Switch + id="log-mode" + checked={logChannelEnabled} + onCheckedChange={() => { + void toggleLogChannel(!logChannelEnabled, serverId).then(() => { + toast({ + title: `Audit & log channel ${ + logChannelEnabled ? 'disabled' : 'enabled' + }` + }); + }); + }} + /> + </div> + ); +} 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 ( + <div className="text-white p-6"> + <h1 className="text-2xl font-bold">Server Not Found</h1> + </div> + ); + } + return ( - <div> - <h2>Guild index page</h2> + <div className="space-y-6"> + <div> + <h1 className="text-3xl font-bold text-slate-900 dark:text-white flex items-center gap-3"> + <Server className="h-8 w-8 text-indigo-500" /> + {guild.name} + </h1> + <p className="text-sm text-slate-600 dark:text-slate-400 mt-1"> + Server ID:{' '} + <code className="text-xs bg-slate-200 dark:bg-slate-700 px-1.5 py-0.5 rounded"> + {guild.id} + </code> + </p> + </div> + + {/* Quick Stats Grid */} + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> + <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700/60 rounded-xl p-5 shadow-sm"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium text-slate-500 dark:text-slate-400"> + Slash Commands + </span> + <Terminal className="h-5 w-5 text-indigo-500" /> + </div> + <div className="mt-3"> + <span className="text-2xl font-bold text-slate-900 dark:text-white"> + {(JSON.parse(guild.disabledCommands || '[]') as string[]).length}{' '} + Disabled + </span> + <p className="text-xs text-slate-500 dark:text-slate-400 mt-1"> + All other commands enabled + </p> + </div> + <div className="mt-4"> + <Button + asChild + size="sm" + className="w-full bg-indigo-600 hover:bg-indigo-500 text-white" + > + <Link href={`/dashboard/${server_id}/commands`}> + Configure Commands + </Link> + </Button> + </div> + </div> + + <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700/60 rounded-xl p-5 shadow-sm"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium text-slate-500 dark:text-slate-400"> + Welcome Message + </span> + <MessageCircle className="h-5 w-5 text-emerald-500" /> + </div> + <div className="mt-3 flex items-center gap-2"> + {guild.welcomeMessageEnabled ? ( + <> + <CheckCircle2 className="h-5 w-5 text-emerald-500" /> + <span className="text-2xl font-bold text-slate-900 dark:text-white"> + Active + </span> + </> + ) : ( + <> + <XCircle className="h-5 w-5 text-rose-500" /> + <span className="text-2xl font-bold text-slate-900 dark:text-white"> + Inactive + </span> + </> + )} + </div> + <p className="text-xs text-slate-500 dark:text-slate-400 mt-1"> + {guild.welcomeMessageEnabled + ? 'Welcoming new members automatically' + : 'Disabled for this guild'} + </p> + <div className="mt-4"> + <Button asChild size="sm" variant="outline" className="w-full"> + <Link href={`/dashboard/${server_id}/welcome-message`}> + Edit Welcome Settings + </Link> + </Button> + </div> + </div> + + <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700/60 rounded-xl p-5 shadow-sm"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium text-slate-500 dark:text-slate-400"> + Audit & Log Channel + </span> + <ScrollText className="h-5 w-5 text-blue-500" /> + </div> + <div className="mt-3 flex items-center gap-2"> + {guild.logChannelEnabled && guild.logChannel ? ( + <> + <CheckCircle2 className="h-5 w-5 text-emerald-500" /> + <span className="text-2xl font-bold text-slate-900 dark:text-white"> + Active + </span> + </> + ) : ( + <> + <XCircle className="h-5 w-5 text-rose-500" /> + <span className="text-2xl font-bold text-slate-900 dark:text-white"> + Inactive + </span> + </> + )} + </div> + <p className="text-xs text-slate-500 dark:text-slate-400 mt-1"> + {guild.logChannelEnabled && guild.logChannel + ? 'Routing moderation logs to channel' + : 'Logging is disabled'} + </p> + <div className="mt-4"> + <Button asChild size="sm" variant="outline" className="w-full"> + <Link href={`/dashboard/${server_id}/log-channel`}> + Edit Log Settings + </Link> + </Button> + </div> + </div> + + <div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700/60 rounded-xl p-5 shadow-sm"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium text-slate-500 dark:text-slate-400"> + Support Tickets + </span> + <LifeBuoy className="h-5 w-5 text-purple-500" /> + </div> + <div className="mt-3 flex items-center gap-2"> + {guild.ticketEnabled && guild.ticketChannel ? ( + <> + <CheckCircle2 className="h-5 w-5 text-emerald-500" /> + <span className="text-2xl font-bold text-slate-900 dark:text-white"> + Active + </span> + </> + ) : ( + <> + <XCircle className="h-5 w-5 text-rose-500" /> + <span className="text-2xl font-bold text-slate-900 dark:text-white"> + Inactive + </span> + </> + )} + </div> + <p className="text-xs text-slate-500 dark:text-slate-400 mt-1"> + {guild.ticketEnabled && guild.ticketChannel + ? 'Thread-based ticket system ready' + : 'Ticket system is disabled'} + </p> + <div className="mt-4"> + <Button asChild size="sm" variant="outline" className="w-full"> + <Link href={`/dashboard/${server_id}/tickets`}> + Edit Ticket Settings + </Link> + </Button> + </div> + </div> + </div> + + {/* Studio Quick Launchers */} + <div className="mt-8 p-6 rounded-2xl bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 shadow-sm"> + <h2 className="text-lg font-bold text-slate-900 dark:text-white mb-4"> + Command Center Studios + </h2> + <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> + <Link + href="/dashboard/music" + className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/60 hover:border-indigo-500/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all flex items-center justify-between group" + > + <div> + <h3 className="text-sm font-semibold text-slate-900 dark:text-white"> + Audio & Music Studio + </h3> + <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5"> + Lavalink v4 queue & DSP + </p> + </div> + <span className="text-xs text-indigo-400 group-hover:translate-x-0.5 transition-transform"> + → + </span> + </Link> + + <Link + href="/dashboard/broadcast" + className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/60 hover:border-indigo-500/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all flex items-center justify-between group" + > + <div> + <h3 className="text-sm font-semibold text-slate-900 dark:text-white"> + Embed Broadcaster + </h3> + <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5"> + WYSIWYG announcements + </p> + </div> + <span className="text-xs text-indigo-400 group-hover:translate-x-0.5 transition-transform"> + → + </span> + </Link> + + <Link + href="/dashboard/integrations" + className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/60 hover:border-indigo-500/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all flex items-center justify-between group" + > + <div> + <h3 className="text-sm font-semibold text-slate-900 dark:text-white"> + Twitch Integrations + </h3> + <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5"> + Live stream alerts + </p> + </div> + <span className="text-xs text-indigo-400 group-hover:translate-x-0.5 transition-transform"> + → + </span> + </Link> + + <Link + href="/dashboard/system" + className="p-4 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700/60 hover:border-indigo-500/50 hover:bg-slate-100 dark:hover:bg-slate-800 transition-all flex items-center justify-between group" + > + <div> + <h3 className="text-sm font-semibold text-slate-900 dark:text-white"> + Cluster Diagnostics + </h3> + <p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5"> + Latency & telemetry metrics + </p> + </div> + <span className="text-xs text-indigo-400 group-hover:translate-x-0.5 transition-transform"> + → + </span> + </Link> + </div> + </div> </div> ); } 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 ( + <div className="flex flex-col gap-6 max-w-5xl"> + {/* Header */} + <div className="flex flex-col gap-2 border-b border-slate-700/60 pb-5"> + <div className="flex items-center gap-3"> + <div className="p-2.5 rounded-xl bg-blue-600/20 border border-blue-500/30 text-blue-400"> + <Bell className="h-6 w-6" /> + </div> + <div> + <h1 className="text-2xl font-bold text-white tracking-tight"> + Reminders Manager + </h1> + <p className="text-sm text-slate-400 mt-0.5"> + Create and manage timed notifications with dynamic formatting tags + and real-time preview. + </p> + </div> + </div> + </div> + + {/* Main Content */} + <div className="flex flex-col gap-8"> + <ReminderForm username={session.user.name ?? 'Member'} /> + <RemindersList initialReminders={reminders} /> + </div> + </div> + ); +} 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 ( - <aside className="flex flex-col items-center gap-10"> - <Link href={`/dashboard/${server_id}`}> - <Logo size="medium" /> - </Link> - <div className="flex flex-col gap-6"> - {links.map(link => ( - <Link - key={link.href} - className="flex gap-4" - href={`/dashboard/${server_id}/${link.href}`} - > - <link.icon size={24} /> - <p className="text-xl">{link.label}</p> + <aside className="w-56 flex flex-col justify-between h-full py-2"> + <div className="flex flex-col gap-8"> + <div className="flex items-center justify-center"> + <Link href={`/dashboard/${server_id}`}> + <Logo size="medium" /> </Link> - ))} + </div> + + <nav className="flex flex-col gap-1.5"> + {links.map(link => { + const isActive = link.exact + ? pathname === link.href + : pathname?.startsWith(link.href); + + return ( + <Link + key={link.href} + href={link.href} + className={`flex items-center gap-3 px-3.5 py-2.5 rounded-lg text-sm font-medium transition-colors ${ + isActive + ? 'bg-slate-700/80 text-white font-semibold shadow-sm' + : 'text-slate-400 hover:text-white hover:bg-slate-800/60' + }`} + > + <link.icon className="h-5 w-5 shrink-0" /> + <span>{link.label}</span> + </Link> + ); + })} + </nav> + </div> + + <div className="pt-4 border-t border-slate-700/50"> + <Link + href="/dashboard" + className="flex items-center gap-3 px-3.5 py-2.5 rounded-lg text-sm font-medium text-slate-400 hover:text-white hover:bg-slate-800/60 transition-colors" + > + <ArrowLeft className="h-5 w-5 shrink-0" /> + <span>Switch Server</span> + </Link> </div> </aside> ); diff --git a/apps/dashboard/src/app/dashboard/[server_id]/tickets/actions.ts b/apps/dashboard/src/app/dashboard/[server_id]/tickets/actions.ts new file mode 100644 index 000000000..1b8c0b427 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/tickets/actions.ts @@ -0,0 +1,125 @@ +'use server'; + +import { prisma } from '@master-bot/db'; +import { revalidatePath } from 'next/cache'; + +async function sendTicketPanelRest(channelId: string, serverId: string) { + const token = process.env.DISCORD_TOKEN; + if (!token || !channelId) return; + + try { + const guild = await prisma.guild.findUnique({ + where: { id: serverId }, + select: { name: true } + }); + + const payload = { + embeds: [ + { + title: `🎫 ${guild?.name ?? 'Server'} Support Tickets`, + description: + 'Need help, have an inquiry, or want to speak with server staff?\n\n' + + 'Click the **Open Ticket** button below to create a private support thread with our moderation team.', + color: 0x5865f2, + footer: { text: 'Support Ticket System • Master-Bot' } + } + ], + components: [ + { + type: 1, + components: [ + { + type: 2, + style: 1, + label: 'Open Ticket', + custom_id: 'ticket_create', + emoji: { name: '🎫' } + } + ] + } + ] + }; + + await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { + method: 'POST', + headers: { + Authorization: `Bot ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + } catch (err) { + console.error('Failed to auto-send ticket panel via REST:', err); + } +} + +export async function toggleTicketSystem(status: boolean, server_id: string) { + const guild = await prisma.guild.update({ + where: { + id: server_id + }, + data: { + ticketEnabled: status + } + }); + + if (status && guild.ticketChannel) { + await sendTicketPanelRest(guild.ticketChannel, server_id); + } + + revalidatePath(`/dashboard/${server_id}/tickets`); + revalidatePath(`/dashboard/${server_id}`); +} + +export async function setTicketChannel( + channelId: string | null, + server_id: string +) { + await prisma.guild.update({ + where: { + id: server_id + }, + data: { + ticketChannel: channelId, + ticketEnabled: Boolean(channelId) + } + }); + + if (channelId) { + await sendTicketPanelRest(channelId, server_id); + } + + revalidatePath(`/dashboard/${server_id}/tickets`); + revalidatePath(`/dashboard/${server_id}`); +} + +export async function setTicketMessage(data: FormData) { + const guildId = data.get('guildId') as string; + const message = data.get('message') as string; + + await prisma.guild.update({ + where: { + id: guildId + }, + data: { + ticketMessage: message + } + }); + + revalidatePath(`/dashboard/${guildId}/tickets`); + revalidatePath(`/dashboard/${guildId}`); +} + +export async function setTicketRole(roleId: string | null, server_id: string) { + await prisma.guild.update({ + where: { + id: server_id + }, + data: { + ticketRoleId: roleId + } + }); + + revalidatePath(`/dashboard/${server_id}/tickets`); + revalidatePath(`/dashboard/${server_id}`); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/tickets/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/tickets/page.tsx new file mode 100644 index 000000000..7620a0c42 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/tickets/page.tsx @@ -0,0 +1,86 @@ +import { prisma } from '@master-bot/db'; +import TicketToggle from './switch'; +import TicketChannelSet from './set-channel'; +import TicketTranscriptChannelSet from './set-transcript-channel'; +import TicketMessageForm from './ticket-form'; +import Link from 'next/link'; + +function getGuildById(id: string) { + return prisma.guild.findUnique({ + where: { + id + } + }); +} + +export default async function TicketsPage({ + params +}: { + params: Promise<{ server_id: string }>; +}) { + const { server_id } = await params; + const guild = await getGuildById(server_id); + + if (!guild) { + return <div>Error loading guild</div>; + } + + return ( + <> + <div className="flex items-center gap-4 mb-2"> + <Link + href={`/dashboard/${server_id}`} + className="text-sm text-gray-400 hover:text-white transition-colors" + > + ← Back to Server + </Link> + </div> + + <h1 className="text-3xl font-semibold">Support Ticket System</h1> + <div className="ml-2 mt-6 flex flex-col gap-6 max-w-5xl"> + <div className="flex flex-col gap-2"> + <h3 className="text-lg text-gray-300"> + Provide members with private, thread-based support and inquiry + management + </h3> + <div className="flex items-center gap-4"> + <span className="text-sm text-gray-400">System Status:</span> + {guild.ticketEnabled && guild.ticketChannel ? ( + <span className="text-sm font-semibold text-green-400 bg-green-950/50 px-2.5 py-1 rounded-full border border-green-800/40"> + 🟢 Enabled + </span> + ) : ( + <span className="text-sm font-semibold text-red-400 bg-red-950/50 px-2.5 py-1 rounded-full border border-red-800/40"> + 🔴 Disabled + </span> + )} + <TicketToggle + ticketEnabled={Boolean(guild.ticketEnabled)} + serverId={server_id} + /> + </div> + </div> + + <div className="rounded-xl border border-gray-800 bg-gray-900/40 p-5 shadow-sm flex flex-col gap-6"> + <TicketChannelSet + guildId={server_id} + initialChannel={guild.ticketChannel} + /> + <hr className="border-gray-800" /> + <TicketTranscriptChannelSet + guildId={server_id} + initialChannel={guild.ticketTranscriptChannel} + /> + </div> + + {guild.ticketEnabled && ( + <TicketMessageForm + guildId={server_id} + initialMessage={guild.ticketMessage ?? ''} + guildName={guild.name || 'Server'} + /> + )} + </div> + </> + ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/tickets/set-channel.tsx b/apps/dashboard/src/app/dashboard/[server_id]/tickets/set-channel.tsx new file mode 100644 index 000000000..7c180b05c --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/tickets/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 TicketChannelSet({ + 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.tickets.setChannel.useMutation(); + + return ( + <div className="flex flex-col gap-4"> + <div> + <h4 className="text-lg font-medium text-white mb-1"> + 📢 Ticket Panel Channel + </h4> + <p className="text-sm text-gray-400"> + Select the text channel where the interactive "Open Ticket" + panel will be hosted. Ticket threads will spawn inside this channel. + </p> + </div> + + {isLoading && !data ? ( + <div className="text-gray-400 text-sm">Loading channels...</div> + ) : ( + <div className="flex flex-col sm:flex-row items-start sm:items-center gap-3"> + <Select onValueChange={setValue} defaultValue={value}> + <SelectTrigger className="w-64 bg-black/60 border-gray-700 text-white"> + <SelectValue placeholder="Select a text channel" /> + </SelectTrigger> + <SelectContent className="bg-slate-900 border-gray-700 text-white"> + {data?.channels.map(channel => ( + <SelectItem key={channel.id} value={channel.id}> + #{channel.name} + </SelectItem> + ))} + </SelectContent> + </Select> + + <Button + type="button" + disabled={!value || isPending} + onClick={() => { + if (!value) return; + mutate( + { + guildId, + channelId: value + }, + { + onSuccess: () => { + toast({ + title: 'Ticket channel updated', + description: + 'Use `/set ticket-panel` in Discord to post or update the ticket creation button.' + }); + }, + onError: () => { + toast({ + title: 'Error setting ticket channel', + description: 'Please try again later.', + variant: 'destructive' + }); + } + } + ); + }} + > + {isPending ? 'Saving...' : 'Save Ticket Channel'} + </Button> + </div> + )} + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/tickets/set-transcript-channel.tsx b/apps/dashboard/src/app/dashboard/[server_id]/tickets/set-transcript-channel.tsx new file mode 100644 index 000000000..acb1fbac2 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/tickets/set-transcript-channel.tsx @@ -0,0 +1,98 @@ +'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 TicketTranscriptChannelSet({ + guildId, + initialChannel +}: { + guildId: string; + initialChannel: string | null; +}) { + const { toast } = useToast(); + const [value, setValue] = useState(initialChannel ?? 'none'); + + const { data, isLoading } = api.channel.getAll.useQuery({ + guildId + }); + + const { mutate, isPending } = api.tickets.setTranscriptChannel.useMutation(); + + return ( + <div className="flex flex-col gap-4"> + <div> + <h4 className="text-lg font-medium text-white mb-1"> + 📑 Ticket Transcripts Channel (Optional) + </h4> + <p className="text-sm text-gray-400"> + When a ticket is closed, Master-Bot compiles all chat messages into a + secure text transcript file and posts it with metadata to this + channel. + </p> + </div> + + {isLoading && !data ? ( + <div className="text-gray-400 text-sm">Loading channels...</div> + ) : ( + <div className="flex flex-col sm:flex-row items-start sm:items-center gap-3"> + <Select onValueChange={setValue} defaultValue={value}> + <SelectTrigger className="w-64 bg-black/60 border-gray-700 text-white"> + <SelectValue placeholder="Select a transcript channel" /> + </SelectTrigger> + <SelectContent className="bg-slate-900 border-gray-700 text-white"> + <SelectItem value="none">🚫 None (Disabled)</SelectItem> + {data?.channels.map(channel => ( + <SelectItem key={channel.id} value={channel.id}> + #{channel.name} + </SelectItem> + ))} + </SelectContent> + </Select> + + <Button + type="button" + disabled={isPending} + onClick={() => { + const channelId = value === 'none' ? null : value; + mutate( + { + guildId, + channelId + }, + { + onSuccess: () => { + toast({ + title: 'Transcript channel updated', + description: channelId + ? 'Ticket transcripts will be archived to this channel upon closure.' + : 'Ticket transcript archiving is now disabled.' + }); + }, + onError: () => { + toast({ + title: 'Error setting transcript channel', + description: 'Please try again later.', + variant: 'destructive' + }); + } + } + ); + }} + > + {isPending ? 'Saving...' : 'Save Transcript Channel'} + </Button> + </div> + )} + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/tickets/switch.tsx b/apps/dashboard/src/app/dashboard/[server_id]/tickets/switch.tsx new file mode 100644 index 000000000..52c14b124 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/tickets/switch.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useToast } from '~/components/ui/use-toast'; +import { Switch } from '~/components/ui/switch'; +import { toggleTicketSystem } from './actions'; + +export default function TicketToggle({ + ticketEnabled, + serverId +}: { + ticketEnabled: boolean; + serverId: string; +}) { + const { toast } = useToast(); + + return ( + <div className="flex items-center space-x-2"> + <Switch + id="ticket-mode" + checked={ticketEnabled} + onCheckedChange={() => { + void toggleTicketSystem(!ticketEnabled, serverId).then(() => { + toast({ + title: `Support ticket system ${ + ticketEnabled ? 'disabled' : 'enabled' + }` + }); + }); + }} + /> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/tickets/ticket-form.tsx b/apps/dashboard/src/app/dashboard/[server_id]/tickets/ticket-form.tsx new file mode 100644 index 000000000..60d396577 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/tickets/ticket-form.tsx @@ -0,0 +1,231 @@ +'use client'; + +import { useState } from 'react'; +import { setTicketMessage } from './actions'; +import { Button } from '~/components/ui/button'; +import { useToast } from '~/components/ui/use-toast'; + +interface TicketFormProps { + guildId: string; + initialMessage: string; + guildName: string; +} + +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.'; + +const TICKET_TAGS = [ + { + tag: '{user}', + alias: '{mention}', + desc: 'Mentions the ticket creator', + example: '@TicketCreator' + }, + { + tag: '{username}', + alias: null, + desc: 'Plain username (no ping)', + example: 'TicketCreator' + }, + { + tag: '{server}', + alias: '{guild}', + desc: 'Name of your Discord server', + example: 'My Community' + } +]; + +export default function TicketMessageForm({ + guildId, + initialMessage, + guildName +}: TicketFormProps) { + const [message, setMessage] = useState(initialMessage || ''); + const [isSaving, setIsSaving] = useState(false); + const { toast } = useToast(); + + const handleInsertTag = (tag: string) => { + setMessage(prev => (prev ? `${prev} ${tag}` : tag)); + }; + + const handleResetToDefault = () => { + setMessage(DEFAULT_TICKET_MESSAGE); + }; + + const generatePreview = (template: string) => { + const raw = + template && template.trim().length > 0 + ? template + : DEFAULT_TICKET_MESSAGE; + return raw + .replace(/\{user\}|\{mention\}/g, '@TicketCreator') + .replace(/\{username\}/g, 'TicketCreator') + .replace(/\{server\}|\{guild\}/g, guildName || 'My Server'); + }; + + const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + setIsSaving(true); + try { + const formData = new FormData(); + formData.append('guildId', guildId); + formData.append('message', message); + await setTicketMessage(formData); + toast({ + title: 'Ticket message saved successfully', + description: + 'New support ticket threads will receive this welcome message.' + }); + } catch { + toast({ + title: 'Error saving ticket message', + description: 'Please try again later.', + variant: 'destructive' + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <div className="flex flex-col gap-6"> + {/* Tag Guide Card */} + <div className="rounded-xl border border-gray-800 bg-gray-900/60 p-5 shadow-sm"> + <h4 className="text-lg font-medium text-white mb-2"> + 🏷️ Dynamic Placeholders & Formatting Tags + </h4> + <p className="text-sm text-gray-400 mb-4"> + Use the tags below in your ticket greeting. When a member opens a + ticket, Master-Bot automatically replaces each tag with real-time + member and server information: + </p> + <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4"> + {TICKET_TAGS.map(item => ( + <div + key={item.tag} + className="flex flex-col justify-between p-3 rounded-lg bg-black/50 border border-gray-800 hover:border-blue-500/50 transition-colors" + > + <div> + <div className="flex items-center gap-2"> + <code className="text-blue-400 font-mono text-sm font-semibold"> + {item.tag} + </code> + {item.alias && ( + <span className="text-xs text-gray-500 font-mono"> + or {item.alias} + </span> + )} + </div> + <p className="text-xs text-gray-400 mt-1">{item.desc}</p> + <p className="text-xs text-gray-500 italic mt-0.5"> + Outputs: {item.example} + </p> + </div> + <Button + type="button" + variant="outline" + size="sm" + className="mt-3 text-xs border-gray-700 hover:bg-blue-600 hover:text-white" + onClick={() => handleInsertTag(item.tag)} + > + + Insert + </Button> + </div> + ))} + </div> + + <div className="rounded-lg bg-blue-950/30 border border-blue-800/40 p-3 text-xs text-blue-300 flex flex-col gap-1"> + <span className="font-semibold text-blue-200"> + ✨ Discord Markdown Supported: + </span> + <span> + • <code>**bold**</code> for bold text, <code>*italics*</code> for + italic, <code>__underline__</code> for underlined text + </span> + <span> + • <code>> Quote</code> for block quotes, <code>`code`</code> for + monospace highlight, <code>• bullet</code> for bullet lists + </span> + </div> + </div> + + {/* Custom Message Editor */} + <form onSubmit={handleFormSubmit} className="flex flex-col gap-4"> + <div className="flex items-center justify-between"> + <label + htmlFor="ticket-text" + className="text-sm font-medium text-gray-200" + > + Custom Ticket Welcome Message + </label> + <button + type="button" + onClick={handleResetToDefault} + className="text-xs text-blue-400 hover:underline" + > + Reset to default professional greeting + </button> + </div> + + <textarea + id="ticket-text" + name="message" + value={message} + onChange={e => setMessage(e.target.value)} + placeholder={DEFAULT_TICKET_MESSAGE} + rows={8} + className="block w-full bg-black/80 outline-none overflow-auto resize-none p-4 text-white rounded-lg border border-gray-800 focus:ring-2 focus:ring-blue-600 focus:border-blue-600 font-sans text-sm" + /> + + {/* Live Preview Box */} + <div className="rounded-lg border border-gray-800 bg-black/40 p-4"> + <span className="text-xs uppercase font-semibold text-gray-500 tracking-wider block mb-1"> + 💬 Live Ticket Thread Embed Preview + </span> + <div className="p-4 rounded-lg bg-[#2b2d31] border border-[#3f4147] text-[#dbdee1] font-sans text-sm space-y-3"> + <div className="border-l-4 border-indigo-500 pl-3 space-y-2"> + <div className="font-bold text-white text-base"> + 🎫 Support Ticket: TicketCreator + </div> + <div className="text-xs whitespace-pre-wrap leading-relaxed text-gray-200"> + {generatePreview(message)} + </div> + <div className="grid grid-cols-2 gap-2 text-xs pt-2 border-t border-gray-700/50"> + <div> + <span className="text-gray-400">👤 Opened By:</span> + <p className="font-medium text-white"> + TicketCreator (@TicketCreator) + </p> + </div> + <div> + <span className="text-gray-400">🕒 Opened At:</span> + <p className="font-medium text-white">Just now</p> + </div> + </div> + </div> + + <div className="pt-2"> + <button + type="button" + className="px-3 py-1.5 rounded bg-rose-600 hover:bg-rose-500 text-white text-xs font-semibold flex items-center gap-1.5" + > + 🔒 Close Ticket + </button> + </div> + </div> + </div> + + <div className="flex gap-3"> + <Button type="submit" disabled={isSaving}> + {isSaving ? 'Saving...' : 'Save Ticket Message'} + </Button> + </div> + </form> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/page.tsx b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/page.tsx index 24562a75b..44aaed7df 100644 --- a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/page.tsx +++ b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/page.tsx @@ -1,8 +1,7 @@ import { prisma } from '@master-bot/db'; import WelcomeMessageToggle from './switch'; -import { setWelcomeMessage } from './actions'; -import { Button } from '~/components/ui/button'; import WelcomeMessageChannelSet from './set-channel'; +import WelcomeMessageForm from './welcome-form'; function getGuildById(id: string) { return prisma.guild.findUnique({ @@ -15,9 +14,10 @@ function getGuildById(id: string) { export default async function WelcomeMessagePage({ params }: { - params: { server_id: string }; + params: Promise<{ server_id: string }>; }) { - const guild = await getGuildById(params.server_id); + const { server_id } = await params; + const guild = await getGuildById(server_id); if (!guild) { return <div>Error loading guild</div>; @@ -26,32 +26,40 @@ export default async function WelcomeMessagePage({ return ( <> <h1 className="text-3xl font-semibold">Welcome Message Settings</h1> - <div className="ml-2 mt-6 flex flex-col gap-6"> - <h3>Welcome new users with a custom message</h3> - <div className="flex items-center gap-5"> - {guild.welcomeMessageEnabled ? ( - <p className="text-green-500">Enabled</p> - ) : ( - <p className="text-red-500">Disabled</p> - )} - <WelcomeMessageToggle - welcomeMessageEnabled={guild.welcomeMessageEnabled} - serverId={params.server_id} - /> + <div className="ml-2 mt-6 flex flex-col gap-6 max-w-4xl"> + <div className="flex flex-col gap-2"> + <h3 className="text-lg text-gray-300"> + Welcome new users with a custom message + </h3> + <div className="flex items-center gap-4"> + <span className="text-sm text-gray-400">System Status:</span> + {guild.welcomeMessageEnabled ? ( + <span className="text-sm font-semibold text-green-400 bg-green-950/50 px-2.5 py-1 rounded-full border border-green-800/40"> + 🟢 Enabled + </span> + ) : ( + <span className="text-sm font-semibold text-red-400 bg-red-950/50 px-2.5 py-1 rounded-full border border-red-800/40"> + 🔴 Disabled + </span> + )} + <WelcomeMessageToggle + welcomeMessageEnabled={guild.welcomeMessageEnabled} + serverId={server_id} + /> + </div> </div> + {guild.welcomeMessageEnabled && ( - <div className="flex flex-col gap-4"> - <form action={setWelcomeMessage}> - <input type="hidden" name="guildId" value={params.server_id} /> - <textarea - name="message" - placeholder="welcome message" - defaultValue={guild.welcomeMessage ?? ''} - className="block mb-2 -ml-1 w-full bg-black outline-none overflow-auto my-2 resize-none p-4 text-white rounded-lg border border-gray-800 focus:ring-blue-600 focus:border-blue-600" - /> - <Button type="submit">Submit</Button> - </form> - <WelcomeMessageChannelSet guildId={params.server_id} /> + <div className="flex flex-col gap-6"> + <div className="rounded-xl border border-gray-800 bg-gray-900/40 p-5"> + <WelcomeMessageChannelSet guildId={server_id} /> + </div> + + <WelcomeMessageForm + guildId={server_id} + initialMessage={guild.welcomeMessage ?? ''} + guildName={guild.name || 'Server'} + /> </div> )} </div> diff --git a/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/welcome-form.tsx b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/welcome-form.tsx new file mode 100644 index 000000000..def285aa4 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/[server_id]/welcome-message/welcome-form.tsx @@ -0,0 +1,202 @@ +'use client'; + +import { useState } from 'react'; +import { setWelcomeMessage } from './actions'; +import { Button } from '~/components/ui/button'; +import { useToast } from '~/components/ui/use-toast'; + +interface WelcomeFormProps { + guildId: string; + initialMessage: string; + guildName: string; +} + +const DEFAULT_TEMPLATE = + '👋 Welcome {user} to **{server}**! You are member #{position}.'; + +const TAGS = [ + { + tag: '{user}', + alias: '{mention}', + desc: 'Mentions the joining member', + example: '@NewMember' + }, + { + tag: '{username}', + alias: null, + desc: 'Plain username (no ping)', + example: 'NewMember' + }, + { + tag: '{server}', + alias: '{guild}', + desc: 'Name of your Discord server', + example: 'My Community' + }, + { + tag: '{position}', + alias: '{memberCount}', + desc: 'Member join number / total count', + example: '142' + } +]; + +export default function WelcomeMessageForm({ + guildId, + initialMessage, + guildName +}: WelcomeFormProps) { + const [message, setMessage] = useState(initialMessage || ''); + const [isSaving, setIsSaving] = useState(false); + const { toast } = useToast(); + + const handleInsertTag = (tag: string) => { + setMessage(prev => (prev ? `${prev} ${tag}` : tag)); + }; + + const handleResetToDefault = () => { + setMessage(DEFAULT_TEMPLATE); + }; + + const generatePreview = (template: string) => { + const raw = + template && template.trim().length > 0 ? template : DEFAULT_TEMPLATE; + return raw + .replace(/\{user\}|\{mention\}/g, '@Member') + .replace(/\{username\}/g, 'Member') + .replace(/\{server\}|\{guild\}/g, guildName || 'My Server') + .replace(/\{memberCount\}|\{position\}/g, '142'); + }; + + const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + setIsSaving(true); + try { + const formData = new FormData(); + formData.append('guildId', guildId); + formData.append('message', message); + await setWelcomeMessage(formData); + toast({ + title: 'Welcome message saved successfully', + description: 'New members will now receive this customized greeting.' + }); + } catch { + toast({ + title: 'Error saving welcome message', + description: 'Please try again later.', + variant: 'destructive' + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <div className="flex flex-col gap-6"> + {/* Tag Guide Card */} + <div className="rounded-xl border border-gray-800 bg-gray-900/60 p-5 shadow-sm"> + <h4 className="text-lg font-medium text-white mb-2"> + 🏷️ Dynamic Placeholders & Formatting Tags + </h4> + <p className="text-sm text-gray-400 mb-4"> + Use the tags below in your custom message. When a user joins, + Master-Bot automatically replaces each tag with real-time member and + server information: + </p> + <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4"> + {TAGS.map(item => ( + <div + key={item.tag} + className="flex items-center justify-between p-3 rounded-lg bg-black/50 border border-gray-800 hover:border-blue-500/50 transition-colors" + > + <div> + <div className="flex items-center gap-2"> + <code className="text-blue-400 font-mono text-sm font-semibold"> + {item.tag} + </code> + {item.alias && ( + <span className="text-xs text-gray-500 font-mono"> + or {item.alias} + </span> + )} + </div> + <p className="text-xs text-gray-400 mt-1">{item.desc}</p> + <p className="text-xs text-gray-500 italic mt-0.5"> + Outputs: {item.example} + </p> + </div> + <Button + type="button" + variant="outline" + size="sm" + className="text-xs border-gray-700 hover:bg-blue-600 hover:text-white" + onClick={() => handleInsertTag(item.tag)} + > + + Insert + </Button> + </div> + ))} + </div> + + <div className="rounded-lg bg-blue-950/30 border border-blue-800/40 p-3 text-xs text-blue-300 flex flex-col gap-1"> + <span className="font-semibold text-blue-200"> + ✨ Discord Markdown Supported: + </span> + <span> + • <code>**bold**</code> for bold text, <code>*italics*</code> for + italic, <code>__underline__</code> for underlined text + </span> + <span> + • <code>> Quote</code> for block quotes, <code>`code`</code> for + monospace highlight + </span> + </div> + </div> + + {/* Custom Message Editor */} + <form onSubmit={handleFormSubmit} className="flex flex-col gap-4"> + <div className="flex items-center justify-between"> + <label + htmlFor="welcome-text" + className="text-sm font-medium text-gray-200" + > + Custom Welcome Message Text + </label> + <button + type="button" + onClick={handleResetToDefault} + className="text-xs text-blue-400 hover:underline" + > + Reset to default greeting + </button> + </div> + + <textarea + id="welcome-text" + name="message" + value={message} + onChange={e => setMessage(e.target.value)} + placeholder={DEFAULT_TEMPLATE} + rows={4} + className="block w-full bg-black/80 outline-none overflow-auto resize-none p-4 text-white rounded-lg border border-gray-800 focus:ring-2 focus:ring-blue-600 focus:border-blue-600 font-sans" + /> + + {/* Live Preview Box */} + <div className="rounded-lg border border-gray-800 bg-black/40 p-4"> + <span className="text-xs uppercase font-semibold text-gray-500 tracking-wider block mb-1"> + 💬 Real-time Discord Preview + </span> + <div className="p-3 rounded bg-[#313338] text-[#dbdee1] text-sm font-sans whitespace-pre-wrap border border-[#3f4147]"> + {generatePreview(message)} + </div> + </div> + + <div className="flex gap-3"> + <Button type="submit" disabled={isSaving}> + {isSaving ? 'Saving...' : 'Save Welcome Message'} + </Button> + </div> + </form> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/broadcast/broadcast-client.tsx b/apps/dashboard/src/app/dashboard/broadcast/broadcast-client.tsx new file mode 100644 index 000000000..1220ed446 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/broadcast/broadcast-client.tsx @@ -0,0 +1,305 @@ +'use client'; + +import { useState } from 'react'; +import { Send, Eye, CheckCircle2, AlertCircle } from 'lucide-react'; +import { api } from '~/utils/api'; + +export default function BroadcastClient() { + const [channelId, setChannelId] = useState<string>(''); + const [content, setContent] = useState<string>(''); + const [title, setTitle] = useState<string>('Server Announcement'); + const [description, setDescription] = useState<string>( + 'Welcome everyone! Here is the latest update regarding our community events and patch notes.' + ); + const [colorHex, setColorHex] = useState<string>('#5865F2'); + const [authorName, setAuthorName] = useState<string>(''); + const [footerText, setFooterText] = useState<string>('Master-Bot System'); + const [statusMessage, setStatusMessage] = useState<{ + type: 'success' | 'error'; + text: string; + } | null>(null); + + const broadcastMutation = api.broadcast.sendBroadcast.useMutation({ + onSuccess: data => { + setStatusMessage({ + type: 'success', + text: `Broadcast sent successfully! Discord Message ID: ${data.messageId}` + }); + }, + onError: err => { + setStatusMessage({ + type: 'error', + text: err.message || 'Failed to dispatch broadcast.' + }); + } + }); + + const handleSend = () => { + if (!channelId) { + setStatusMessage({ + type: 'error', + text: 'Please enter a target Channel ID.' + }); + return; + } + + const colorInt = parseInt(colorHex.replace('#', ''), 16) || 0x5865f2; + + broadcastMutation.mutate({ + guildId: '0', + channelId, + content: content || undefined, + embed: { + title: title || undefined, + description: description || undefined, + color: colorInt, + author: authorName ? { name: authorName } : undefined, + footer: footerText ? { text: footerText } : undefined + } + }); + }; + + return ( + <div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> + {/* Left Column: Embed Form Builder */} + <div className="space-y-6"> + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl space-y-4"> + <h2 className="text-lg font-bold text-white flex items-center gap-2"> + <Send className="w-5 h-5 text-indigo-400" /> + <span>Broadcast Configuration</span> + </h2> + + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> + <div> + <label + htmlFor="target-channel-id" + className="block text-xs font-semibold text-slate-300 mb-1" + > + Target Channel ID * + </label> + <input + id="target-channel-id" + type="text" + placeholder="e.g. 102938475610293847" + value={channelId} + onChange={e => setChannelId(e.target.value)} + className="w-full px-3.5 py-2 rounded-xl bg-slate-800/80 border border-slate-700 text-white text-sm focus:outline-none focus:border-indigo-500 font-mono" + /> + </div> + + <div> + <label + htmlFor="accent-color-hex" + className="block text-xs font-semibold text-slate-300 mb-1" + > + Accent Color + </label> + <div className="flex items-center gap-2"> + <input + id="accent-color-picker" + type="color" + value={colorHex} + onChange={e => setColorHex(e.target.value)} + className="w-9 h-9 rounded-lg border border-slate-700 bg-slate-800 cursor-pointer p-0.5" + /> + <input + id="accent-color-hex" + type="text" + value={colorHex} + onChange={e => setColorHex(e.target.value)} + className="w-full px-3.5 py-2 rounded-xl bg-slate-800/80 border border-slate-700 text-white text-sm font-mono focus:outline-none focus:border-indigo-500" + /> + </div> + </div> + </div> + + <div> + <label + htmlFor="broadcast-plaintext" + className="block text-xs font-semibold text-slate-300 mb-1" + > + Plaintext Message (Optional) + </label> + <input + id="broadcast-plaintext" + type="text" + placeholder="e.g. @everyone Announcement!" + value={content} + onChange={e => setContent(e.target.value)} + className="w-full px-3.5 py-2 rounded-xl bg-slate-800/80 border border-slate-700 text-white text-sm focus:outline-none focus:border-indigo-500" + /> + </div> + + <div> + <label + htmlFor="broadcast-title" + className="block text-xs font-semibold text-slate-300 mb-1" + > + Embed Title + </label> + <input + id="broadcast-title" + type="text" + value={title} + onChange={e => setTitle(e.target.value)} + className="w-full px-3.5 py-2 rounded-xl bg-slate-800/80 border border-slate-700 text-white text-sm focus:outline-none focus:border-indigo-500" + /> + </div> + + <div> + <label + htmlFor="broadcast-description" + className="block text-xs font-semibold text-slate-300 mb-1" + > + Embed Description + </label> + <textarea + id="broadcast-description" + rows={4} + value={description} + onChange={e => setDescription(e.target.value)} + className="w-full px-3.5 py-2 rounded-xl bg-slate-800/80 border border-slate-700 text-white text-sm focus:outline-none focus:border-indigo-500 resize-y" + /> + </div> + + <div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> + <div> + <label + htmlFor="broadcast-author" + className="block text-xs font-semibold text-slate-300 mb-1" + > + Author Name + </label> + <input + id="broadcast-author" + type="text" + placeholder="e.g. Server Staff" + value={authorName} + onChange={e => setAuthorName(e.target.value)} + className="w-full px-3.5 py-2 rounded-xl bg-slate-800/80 border border-slate-700 text-white text-sm focus:outline-none focus:border-indigo-500" + /> + </div> + + <div> + <label + htmlFor="broadcast-footer" + className="block text-xs font-semibold text-slate-300 mb-1" + > + Footer Text + </label> + <input + id="broadcast-footer" + type="text" + value={footerText} + onChange={e => setFooterText(e.target.value)} + className="w-full px-3.5 py-2 rounded-xl bg-slate-800/80 border border-slate-700 text-white text-sm focus:outline-none focus:border-indigo-500" + /> + </div> + </div> + + {statusMessage && ( + <div + className={`p-3.5 rounded-xl border flex items-center gap-2.5 text-xs font-medium ${ + statusMessage.type === 'success' + ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' + : 'bg-red-500/10 border-red-500/20 text-red-300' + }`} + > + {statusMessage.type === 'success' ? ( + <CheckCircle2 className="w-4 h-4 shrink-0" /> + ) : ( + <AlertCircle className="w-4 h-4 shrink-0" /> + )} + <span>{statusMessage.text}</span> + </div> + )} + + <button + onClick={handleSend} + disabled={broadcastMutation.isPending} + className="w-full py-3 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-semibold text-sm shadow-lg shadow-indigo-600/30 transition-all flex items-center justify-center gap-2" + > + <Send className="w-4 h-4" /> + <span> + {broadcastMutation.isPending + ? 'Broadcasting...' + : 'Send Broadcast to Discord'} + </span> + </button> + </div> + </div> + + {/* Right Column: Live Discord WYSIWYG Preview */} + <div className="space-y-4"> + <div className="flex items-center gap-2 text-slate-400 text-xs font-semibold uppercase tracking-wider"> + <Eye className="w-4 h-4 text-indigo-400" /> + <span>Live Discord Client Preview</span> + </div> + + {/* Discord Message Shell */} + <div className="p-6 rounded-2xl bg-[#313338] border border-slate-800 shadow-2xl font-sans"> + <div className="flex items-start gap-4"> + {/* Bot Avatar */} + <div className="w-10 h-10 rounded-full bg-indigo-600 flex items-center justify-center text-white font-bold text-sm shrink-0"> + MB + </div> + + <div className="flex-1 min-w-0"> + {/* Bot Header Info */} + <div className="flex items-center gap-2"> + <span className="font-semibold text-white text-sm"> + Master-Bot + </span> + <span className="bg-[#5865f2] text-white text-[10px] font-bold px-1.5 py-0.5 rounded uppercase"> + BOT + </span> + <span className="text-[#949ba4] text-xs"> + Today at{' '} + {new Date().toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + })} + </span> + </div> + + {/* Plain text if any */} + {content && ( + <p className="text-[#dbdee1] text-sm mt-1 whitespace-pre-wrap"> + {content} + </p> + )} + + {/* Rich Embed Card */} + <div + className="mt-2.5 rounded border-l-4 bg-[#2b2d31] p-4 max-w-lg shadow-sm" + style={{ borderLeftColor: colorHex || '#5865F2' }} + > + {authorName && ( + <p className="text-xs font-medium text-white mb-1.5"> + {authorName} + </p> + )} + + {title && ( + <h4 className="text-sm font-bold text-white mb-1">{title}</h4> + )} + + {description && ( + <p className="text-xs text-[#dbdee1] whitespace-pre-wrap leading-relaxed"> + {description} + </p> + )} + + {footerText && ( + <p className="text-[11px] text-[#949ba4] mt-3 pt-2 border-t border-[#3f4147]"> + {footerText} + </p> + )} + </div> + </div> + </div> + </div> + </div> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/broadcast/page.tsx b/apps/dashboard/src/app/dashboard/broadcast/page.tsx new file mode 100644 index 000000000..878c2a665 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/broadcast/page.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link'; +import { auth } from '@master-bot/auth'; +import { redirect } from 'next/navigation'; +import { Send, ArrowLeft, Radio } from 'lucide-react'; +import BroadcastClient from './broadcast-client'; + +export default async function BroadcastPage() { + const session = await auth(); + + if (!session) { + redirect('/'); + } + + return ( + <div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col"> + {/* Top Bar */} + <header className="py-4 px-6 border-b border-slate-800 bg-slate-900/60 backdrop-blur-md flex items-center justify-between sticky top-0 z-40"> + <div className="flex items-center gap-4"> + <Link + href="/dashboard" + className="flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors" + > + <ArrowLeft className="w-4 h-4" /> + <span>Dashboard</span> + </Link> + <span className="text-slate-700">/</span> + <div className="flex items-center gap-2"> + <Send className="w-5 h-5 text-indigo-400" /> + <h1 className="text-lg font-bold text-white"> + Embed Broadcaster Studio + </h1> + </div> + </div> + + <div className="flex items-center gap-3"> + <span className="px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold flex items-center gap-1.5"> + <Radio className="w-3.5 h-3.5" /> + WYSIWYG Live Renderer + </span> + </div> + </header> + + {/* Studio Content */} + <main className="flex-1 p-6 max-w-7xl mx-auto w-full"> + <BroadcastClient /> + </main> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/guilds.tsx b/apps/dashboard/src/app/dashboard/guilds.tsx index 99e745fb7..17512fe4a 100644 --- a/apps/dashboard/src/app/dashboard/guilds.tsx +++ b/apps/dashboard/src/app/dashboard/guilds.tsx @@ -1,9 +1,18 @@ 'use client'; import Link from 'next/link'; +import { Settings } from 'lucide-react'; import { Button } from '~/components/ui/button'; import { api } from '~/utils/api'; -import { env } from '~/env.mjs'; + +const GRADIENTS = [ + 'from-indigo-500 to-purple-500', + 'from-emerald-500 to-teal-500', + 'from-rose-500 to-pink-500', + 'from-amber-500 to-orange-500', + 'from-sky-500 to-blue-500', + 'from-fuchsia-500 to-purple-500' +]; export default function GuildsList() { const { data, isLoading, isError } = api.guild.getAll.useQuery(undefined, { @@ -16,42 +25,53 @@ export default function GuildsList() { if (isError) return <div className="text-white">Error</div>; + if (!data || data.guilds.length === 0) { + return ( + <div> + <p className="text-white">The bot is not in any servers yet</p> + </div> + ); + } + return ( - <> - {data ? ( - <div className="flex gap-14"> - {data.apiGuilds.map(guild => ( - <div - className="text-white flex flex-col items-center" - key={guild.id} + <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 w-full"> + {data.guilds.map((guild, index) => ( + <div + key={guild.id} + className="group p-6 rounded-2xl bg-slate-900/60 border border-slate-800 hover:border-slate-700 hover:bg-slate-900/80 transition-colors duration-200 shadow-md flex flex-col items-center text-center" + > + <div + className={`w-16 h-16 rounded-2xl flex items-center justify-center text-2xl font-bold text-white mb-4 shadow-lg overflow-hidden ${guild.icon ? '' : `bg-gradient-to-br ${GRADIENTS[index % GRADIENTS.length]}`}`} + > + {guild.icon ? ( + // eslint-disable-next-line @next/next/no-img-element + <img + src={`https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128`} + alt={guild.name} + className="w-full h-full object-cover" + /> + ) : ( + guild.name.charAt(0).toUpperCase() + )} + </div> + <h3 className="text-base font-semibold text-slate-100 truncate max-w-full px-1"> + {guild.name} + </h3> + <p className="mt-1 text-xs text-slate-500 font-mono">{guild.id}</p> + <Button + className="mt-5 w-full bg-orange-500 hover:bg-orange-600 text-white" + asChild + > + <Link + href={`/dashboard/${guild.id}`} + className="flex items-center justify-center gap-2" > - <p className="font-semibold text-lg">{guild.name}</p> - {data.dbGuildsIds.includes(guild.id) ? ( - <Button - className="bg-orange-500 hover:bg-orange-600 text-white" - asChild - > - <Link href={`/dashboard/${guild.id}`}>Manage</Link> - </Button> - ) : ( - <Button variant="link" asChild> - <a - href={env.NEXT_PUBLIC_INVITE_URL} - target="_blank" - rel="noreferrer" - > - Invite - </a> - </Button> - )} - </div> - ))} - </div> - ) : ( - <div> - <p className="text-white">You do not own a Discord server</p> + <Settings className="w-4 h-4" /> + Manage + </Link> + </Button> </div> - )} - </> + ))} + </div> ); -} +} \ No newline at end of file diff --git a/apps/dashboard/src/app/dashboard/integrations/integrations-client.tsx b/apps/dashboard/src/app/dashboard/integrations/integrations-client.tsx new file mode 100644 index 000000000..847460811 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/integrations/integrations-client.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { useState } from 'react'; +import { Plus, Video, Bell } from 'lucide-react'; + +export default function IntegrationsClient() { + const [streamerName, setStreamerName] = useState<string>(''); + const [guildId, setGuildId] = useState<string>(''); + const [channelId, setChannelId] = useState<string>(''); + + return ( + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> + {/* Left Column: Register New Streamer (1 col) */} + <div className="space-y-6"> + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl space-y-4"> + <h2 className="text-lg font-bold text-white flex items-center gap-2"> + <Video className="w-5 h-5 text-purple-400" /> + <span>Track Streamer</span> + </h2> + + <div> + <label + htmlFor="twitch-username" + className="block text-xs font-semibold text-slate-300 mb-1" + > + Twitch Username * + </label> + <input + id="twitch-username" + type="text" + placeholder="e.g. shroud" + value={streamerName} + onChange={e => setStreamerName(e.target.value)} + className="w-full px-3.5 py-2 rounded-xl bg-slate-800/80 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500 font-mono" + /> + </div> + + <div> + <label + htmlFor="twitch-guild-id" + className="block text-xs font-semibold text-slate-300 mb-1" + > + Guild ID * + </label> + <input + id="twitch-guild-id" + type="text" + placeholder="e.g. 102938475610293847" + value={guildId} + onChange={e => setGuildId(e.target.value)} + className="w-full px-3.5 py-2 rounded-xl bg-slate-800/80 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500 font-mono" + /> + </div> + + <div> + <label + htmlFor="twitch-channel-id" + className="block text-xs font-semibold text-slate-300 mb-1" + > + Notification Channel ID * + </label> + <input + id="twitch-channel-id" + type="text" + placeholder="e.g. 987654321098765432" + value={channelId} + onChange={e => setChannelId(e.target.value)} + className="w-full px-3.5 py-2 rounded-xl bg-slate-800/80 border border-slate-700 text-white text-sm focus:outline-none focus:border-purple-500 font-mono" + /> + </div> + + <button className="w-full py-3 rounded-xl bg-purple-600 hover:bg-purple-500 text-white font-semibold text-sm shadow-lg shadow-purple-600/30 transition-all flex items-center justify-center gap-2"> + <Plus className="w-4 h-4" /> + <span>Add Twitch Subscription</span> + </button> + </div> + </div> + + {/* Right Column: Tracked Streamers List (2 cols) */} + <div className="lg:col-span-2 space-y-6"> + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl flex flex-col h-full"> + <div className="flex items-center justify-between mb-4"> + <div className="flex items-center gap-2"> + <Bell className="w-5 h-5 text-purple-400" /> + <h3 className="text-base font-semibold text-white"> + Active Twitch Live Notifications + </h3> + </div> + </div> + + <div className="py-16 text-center text-xs text-slate-500"> + <Video className="w-10 h-10 mx-auto text-slate-700 mb-3" /> + No streamer subscriptions configured. Enter a Twitch handle to + receive automated stream notifications when they go live. + </div> + </div> + </div> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/integrations/page.tsx b/apps/dashboard/src/app/dashboard/integrations/page.tsx new file mode 100644 index 000000000..646eb48c9 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/integrations/page.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link'; +import { auth } from '@master-bot/auth'; +import { redirect } from 'next/navigation'; +import { Layers, ArrowLeft, Radio } from 'lucide-react'; +import IntegrationsClient from './integrations-client'; + +export default async function IntegrationsPage() { + const session = await auth(); + + if (!session) { + redirect('/'); + } + + return ( + <div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col"> + {/* Top Bar */} + <header className="py-4 px-6 border-b border-slate-800 bg-slate-900/60 backdrop-blur-md flex items-center justify-between sticky top-0 z-40"> + <div className="flex items-center gap-4"> + <Link + href="/dashboard" + className="flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors" + > + <ArrowLeft className="w-4 h-4" /> + <span>Dashboard</span> + </Link> + <span className="text-slate-700">/</span> + <div className="flex items-center gap-2"> + <Layers className="w-5 h-5 text-indigo-400" /> + <h1 className="text-lg font-bold text-white"> + Twitch & Stream Integrations + </h1> + </div> + </div> + + <div className="flex items-center gap-3"> + <span className="px-2.5 py-1 rounded-full bg-purple-500/10 border border-purple-500/20 text-purple-400 text-xs font-semibold flex items-center gap-1.5"> + <Radio className="w-3.5 h-3.5" /> + Twitch EventSub Active + </span> + </div> + </header> + + {/* Studio Content */} + <main className="flex-1 p-6 max-w-7xl mx-auto w-full"> + <IntegrationsClient /> + </main> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/music/music-client.tsx b/apps/dashboard/src/app/dashboard/music/music-client.tsx new file mode 100644 index 000000000..9281ec448 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/music/music-client.tsx @@ -0,0 +1,194 @@ +'use client'; + +import { useState } from 'react'; +import { + Music, + Play, + Pause, + SkipForward, + Volume2, + Sliders, + ListMusic, + Radio, + Plus, + Trash2 +} from 'lucide-react'; +import { api } from '~/utils/api'; + +export default function MusicStudioClient() { + const [volume, setVolume] = useState<number>(100); + const [isPlaying, setIsPlaying] = useState<boolean>(false); + const [selectedFilter, setSelectedFilter] = useState<string>('none'); + + const { data: playlistsData, isLoading: isLoadingPlaylists } = + api.music.getUserPlaylists.useQuery(); + + const filters = [ + { id: 'none', label: 'Flat (Default)' }, + { id: 'bassboost', label: 'Bass Boost 8D' }, + { id: 'nightcore', label: 'Nightcore (+Pitch)' }, + { id: 'vaporwave', label: 'Vaporwave (Slowed)' }, + { id: 'karaoke', label: 'Vocal Isolator' } + ]; + + return ( + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> + {/* Left Column: Player & Active Queue (2 cols) */} + <div className="lg:col-span-2 space-y-6"> + {/* Now Playing Card */} + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl"> + <div className="flex items-center justify-between mb-4"> + <span className="text-xs font-semibold uppercase tracking-wider text-indigo-400"> + Now Playing + </span> + <span className="px-2 py-0.5 rounded-md bg-slate-800 text-xs text-slate-400"> + Queue: 0 tracks + </span> + </div> + + <div className="flex flex-col sm:flex-row items-center gap-6 py-4"> + <div className="w-28 h-28 rounded-xl bg-slate-800/80 border border-slate-700 flex items-center justify-center shrink-0 shadow-inner"> + <Music className="w-12 h-12 text-slate-600" /> + </div> + + <div className="flex-1 text-center sm:text-left"> + <h2 className="text-xl font-bold text-white">No Track Playing</h2> + <p className="text-sm text-slate-400 mt-1"> + Queue a song via Discord command{' '} + <code className="text-indigo-400">/play</code> or select from + your playlists below. + </p> + + {/* Progress Bar Placeholder */} + <div className="mt-4 space-y-1"> + <div className="w-full bg-slate-800 rounded-full h-1.5 overflow-hidden"> + <div className="bg-indigo-500 h-full w-0" /> + </div> + <div className="flex justify-between text-xs text-slate-500"> + <span>0:00</span> + <span>0:00</span> + </div> + </div> + </div> + </div> + + {/* Player Controls Bar */} + <div className="mt-6 pt-6 border-t border-slate-800 flex flex-wrap items-center justify-between gap-4"> + <div className="flex items-center gap-3"> + <button + onClick={() => setIsPlaying(!isPlaying)} + className="w-11 h-11 rounded-full bg-indigo-600 hover:bg-indigo-500 text-white flex items-center justify-center shadow-lg shadow-indigo-600/30 transition-all" + > + {isPlaying ? ( + <Pause className="w-5 h-5 fill-current" /> + ) : ( + <Play className="w-5 h-5 fill-current ml-0.5" /> + )} + </button> + + <button className="w-9 h-9 rounded-full bg-slate-800 hover:bg-slate-700 text-slate-300 flex items-center justify-center transition-colors"> + <SkipForward className="w-4 h-4" /> + </button> + </div> + + {/* Volume Slider */} + <div className="flex items-center gap-3 w-48"> + <Volume2 className="w-4 h-4 text-slate-400 shrink-0" /> + <input + type="range" + min="0" + max="150" + value={volume} + onChange={e => setVolume(Number(e.target.value))} + className="w-full accent-indigo-500 bg-slate-800 h-1.5 rounded-lg cursor-pointer" + /> + <span className="text-xs font-mono text-slate-400 w-8 text-right"> + {volume}% + </span> + </div> + </div> + </div> + + {/* Audio DSP Filters */} + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl"> + <div className="flex items-center gap-2 mb-4"> + <Sliders className="w-4 h-4 text-indigo-400" /> + <h3 className="text-base font-semibold text-white"> + Audio DSP Filters + </h3> + </div> + + <div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> + {filters.map(f => ( + <button + key={f.id} + onClick={() => setSelectedFilter(f.id)} + className={`px-4 py-2.5 rounded-xl text-xs font-medium border transition-all text-left ${ + selectedFilter === f.id + ? 'bg-indigo-600/20 border-indigo-500 text-indigo-300 font-semibold shadow-sm' + : 'bg-slate-800/40 border-slate-700/60 text-slate-400 hover:text-slate-200 hover:bg-slate-800/80' + }`} + > + {f.label} + </button> + ))} + </div> + </div> + </div> + + {/* Right Column: User Saved Playlists (1 col) */} + <div className="space-y-6"> + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl flex flex-col h-full"> + <div className="flex items-center justify-between mb-4"> + <div className="flex items-center gap-2"> + <ListMusic className="w-4 h-4 text-indigo-400" /> + <h3 className="text-base font-semibold text-white"> + Saved Playlists + </h3> + </div> + <button className="px-2.5 py-1 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium flex items-center gap-1 transition-colors"> + <Plus className="w-3.5 h-3.5" /> + <span>New</span> + </button> + </div> + + <div className="flex-1 space-y-3 overflow-y-auto max-h-[480px]"> + {isLoadingPlaylists ? ( + <div className="py-8 text-center text-xs text-slate-500"> + Loading your playlists... + </div> + ) : playlistsData?.playlists?.length ? ( + playlistsData.playlists.map(pl => ( + <div + key={pl.id} + className="p-3.5 rounded-xl bg-slate-800/50 border border-slate-700/60 hover:border-slate-600 transition-all flex items-center justify-between" + > + <div> + <p className="text-sm font-semibold text-white"> + {pl.name} + </p> + <p className="text-xs text-slate-400"> + {pl.songs.length}{' '} + {pl.songs.length === 1 ? 'song' : 'songs'} + </p> + </div> + + <button className="p-1.5 rounded-lg text-slate-400 hover:text-red-400 hover:bg-red-500/10 transition-colors"> + <Trash2 className="w-4 h-4" /> + </button> + </div> + )) + ) : ( + <div className="py-12 text-center text-xs text-slate-500"> + <Radio className="w-8 h-8 mx-auto text-slate-600 mb-2 opacity-50" /> + No playlists saved yet. Use{' '} + <code className="text-indigo-400">/save-to-playlist</code> in + Discord. + </div> + )} + </div> + </div> + </div> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/music/page.tsx b/apps/dashboard/src/app/dashboard/music/page.tsx new file mode 100644 index 000000000..1b83edeb7 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/music/page.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link'; +import { auth } from '@master-bot/auth'; +import { redirect } from 'next/navigation'; +import { Music, Disc3, ArrowLeft } from 'lucide-react'; +import MusicStudioClient from './music-client'; + +export default async function MusicStudioPage() { + const session = await auth(); + + if (!session) { + redirect('/'); + } + + return ( + <div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col"> + {/* Top Bar */} + <header className="py-4 px-6 border-b border-slate-800 bg-slate-900/60 backdrop-blur-md flex items-center justify-between sticky top-0 z-40"> + <div className="flex items-center gap-4"> + <Link + href="/dashboard" + className="flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors" + > + <ArrowLeft className="w-4 h-4" /> + <span>Dashboard</span> + </Link> + <span className="text-slate-700">/</span> + <div className="flex items-center gap-2"> + <Music className="w-5 h-5 text-indigo-400" /> + <h1 className="text-lg font-bold text-white"> + Audio & Music Studio + </h1> + </div> + </div> + + <div className="flex items-center gap-3"> + <span className="px-2.5 py-1 rounded-full bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 text-xs font-semibold flex items-center gap-1.5"> + <Disc3 className="w-3.5 h-3.5 animate-spin" /> + Lavalink v4 Node Online + </span> + </div> + </header> + + {/* Studio Content */} + <main className="flex-1 p-6 max-w-7xl mx-auto w-full"> + <MusicStudioClient /> + </main> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/page.tsx b/apps/dashboard/src/app/dashboard/page.tsx index 49233fe30..7a3a867af 100644 --- a/apps/dashboard/src/app/dashboard/page.tsx +++ b/apps/dashboard/src/app/dashboard/page.tsx @@ -11,14 +11,22 @@ export default async function DashboardIndexPage() { } return ( - <div className="bg-slate-900 h-screen"> - <header className="py-4 px-5"> + <div className="bg-slate-900 min-h-screen"> + <header className="py-4 px-6 flex items-center justify-between border-b border-slate-800"> <Link href="/"> - <h3 className="text-white hover:underline">Go back</h3> + <h3 className="text-slate-300 hover:text-white transition-colors"> + ← Go back + </h3> + </Link> + <Link + href="/dashboard/reminders" + className="px-3.5 py-1.5 rounded-lg bg-blue-600/90 hover:bg-blue-600 text-white text-sm font-medium transition-colors flex items-center gap-2 shadow-sm" + > + <span>⏰ My Reminders</span> </Link> </header> - <main className="flex flex-col items-center justify-center mx-80"> - <h1 className="text-white text-5xl font-semibold mb-10"> + <main className="w-full max-w-6xl mx-auto px-6 py-10"> + <h1 className="text-white text-3xl sm:text-4xl font-semibold mb-10 text-center"> Select a guild </h1> <GuildsList /> diff --git a/apps/dashboard/src/app/dashboard/reminders/actions.ts b/apps/dashboard/src/app/dashboard/reminders/actions.ts new file mode 100644 index 000000000..7aa2a1280 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/reminders/actions.ts @@ -0,0 +1,69 @@ +'use server'; + +import { auth } from '@master-bot/auth'; +import { prisma } from '@master-bot/db'; +import { revalidatePath } from 'next/cache'; + +export async function createReminder(formData: FormData) { + const session = await auth(); + if (!session?.user) { + throw new Error('Unauthorized'); + } + const discordId = (session.user as any).discordId || session.user.id; + + const event = (formData.get('event') as string)?.trim(); + const description = (formData.get('description') as string)?.trim() || null; + const dateTime = formData.get('dateTime') as string; + + if (!event) throw new Error('Event title is required'); + if (!dateTime) throw new Error('Date and time are required'); + + const targetDate = new Date(dateTime); + if (isNaN(targetDate.getTime()) || targetDate.getTime() <= Date.now()) { + throw new Error('Please select a valid future date and time'); + } + + const guild = await prisma.guild.findFirst({ + where: { ownerId: discordId }, + select: { id: true } + }); + if (!guild) { + throw new Error('You must own a server before creating reminders'); + } + + await prisma.reminder.create({ + data: { + event, + description, + dateTime: targetDate.toISOString(), + repeat: null, + timeOffset: 0, + guild: { connect: { id: guild.id } }, + user: { connect: { discordId } } + } + }); + + revalidatePath('/dashboard/reminders'); +} + +export async function deleteReminder(formData: FormData) { + const session = await auth(); + if (!session?.user) { + throw new Error('Unauthorized'); + } + const discordId = (session.user as any).discordId || session.user.id; + + const idStr = formData.get('id') as string; + const id = parseInt(idStr, 10); + + if (isNaN(id)) throw new Error('Invalid reminder ID'); + + await prisma.reminder.deleteMany({ + where: { + id, + userId: discordId + } + }); + + revalidatePath('/dashboard/reminders'); +} diff --git a/apps/dashboard/src/app/dashboard/reminders/page.tsx b/apps/dashboard/src/app/dashboard/reminders/page.tsx new file mode 100644 index 000000000..4623786e7 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/reminders/page.tsx @@ -0,0 +1,73 @@ +import { auth } from '@master-bot/auth'; +import { prisma } from '@master-bot/db'; +import { redirect } from 'next/navigation'; +import Link from 'next/link'; +import { ArrowLeft, Bell } from 'lucide-react'; +import ReminderForm from './reminder-form'; +import RemindersList from './reminders-list'; + +export default async function RemindersPage() { + 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 ( + <div className="min-h-screen bg-slate-950 text-slate-100 p-6 md:p-10"> + <div className="max-w-5xl mx-auto flex flex-col gap-8"> + {/* Top Navigation Bar */} + <div className="flex items-center justify-between"> + <Link + href="/dashboard" + className="inline-flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors" + > + <ArrowLeft className="h-4 w-4" /> + <span>Back to Dashboard</span> + </Link> + </div> + + {/* Header */} + <div className="flex flex-col gap-2 border-b border-slate-800 pb-6"> + <div className="flex items-center gap-3"> + <div className="p-3 rounded-xl bg-blue-950/80 border border-blue-800/60 text-blue-400"> + <Bell className="h-6 w-6" /> + </div> + <div> + <h1 className="text-2xl md:text-3xl font-bold text-white tracking-tight"> + Reminders Manager + </h1> + <p className="text-sm text-slate-400 mt-0.5"> + Create and manage custom timed reminders with dynamic format + tags and Discord notifications. + </p> + </div> + </div> + </div> + + {/* Main Content Grid */} + <div className="flex flex-col gap-8"> + <ReminderForm username={session.user.name ?? 'Member'} /> + <RemindersList initialReminders={reminders} /> + </div> + </div> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/reminders/reminder-form.tsx b/apps/dashboard/src/app/dashboard/reminders/reminder-form.tsx new file mode 100644 index 000000000..8d7630c24 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/reminders/reminder-form.tsx @@ -0,0 +1,315 @@ +'use client'; + +import { useState } from 'react'; +import { createReminder } from './actions'; +import { Button } from '~/components/ui/button'; +import { useToast } from '~/components/ui/use-toast'; +import { PlusCircle, Tag, Clock } from 'lucide-react'; + +interface ReminderFormProps { + username: string; +} + +const TAGS = [ + { + tag: '{user}', + alias: '{mention}', + desc: 'Mentions you directly', + example: '@User' + }, + { + tag: '{username}', + alias: null, + desc: 'Your plain username', + example: 'User' + }, + { + tag: '{event}', + alias: null, + desc: 'The title of this event', + example: 'Team Meeting' + }, + { + tag: '{date}', + alias: null, + desc: 'Formatted date of the reminder', + example: 'August 31, 2026' + }, + { + tag: '{time}', + alias: null, + desc: 'Formatted time of the reminder', + example: '7:30 PM' + }, + { + tag: '{countdown}', + alias: '{relative}', + desc: 'Relative countdown timestamp', + example: 'in 2 hours' + } +]; + +export default function ReminderForm({ username }: ReminderFormProps) { + const [event, setEvent] = useState(''); + const [description, setDescription] = useState(''); + // Default to 1 hour in the future + const defaultDate = new Date(Date.now() + 60 * 60 * 1000); + const defaultIso = new Date( + defaultDate.getTime() - defaultDate.getTimezoneOffset() * 60000 + ) + .toISOString() + .slice(0, 16); + + const [dateTime, setDateTime] = useState(defaultIso); + const [isSaving, setIsSaving] = useState(false); + const { toast } = useToast(); + + const handleInsertTag = (tag: string) => { + setDescription(prev => (prev ? `${prev} ${tag}` : tag)); + }; + + const generatePreview = (text: string) => { + if (!text) return 'No additional notes provided.'; + const targetDate = new Date(dateTime); + const dateStr = !isNaN(targetDate.getTime()) + ? targetDate.toLocaleDateString('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric' + }) + : 'August 31, 2026'; + const timeStr = !isNaN(targetDate.getTime()) + ? targetDate.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }) + : '7:30 PM'; + + return text + .replace(/\{user\}|\{mention\}/gi, `@${username || 'Member'}`) + .replace(/\{username\}/gi, username || 'Member') + .replace(/\{event\}/gi, event || 'My Scheduled Event') + .replace(/\{date\}/gi, dateStr) + .replace(/\{time\}/gi, timeStr) + .replace(/\{countdown\}|\{relative\}|\{timestamp\}/gi, 'in 1 hour'); + }; + + const handleFormSubmit = async (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault(); + if (!event.trim()) { + return toast({ + title: 'Event title required', + description: 'Please provide a name or title for your reminder.', + variant: 'destructive' + }); + } + + if (!dateTime) { + return toast({ + title: 'Date and time required', + description: 'Please select when you want to be reminded.', + variant: 'destructive' + }); + } + + const parsedDate = new Date(dateTime); + if (isNaN(parsedDate.getTime()) || parsedDate.getTime() <= Date.now()) { + return toast({ + title: 'Invalid reminder time', + description: 'Please select a future date and time.', + variant: 'destructive' + }); + } + + setIsSaving(true); + try { + const formData = new FormData(); + formData.append('event', event); + formData.append('description', description); + formData.append('dateTime', dateTime); + + await createReminder(formData); + toast({ + title: '⏰ Reminder scheduled successfully', + description: `You will be notified for "${event}".` + }); + setEvent(''); + setDescription(''); + } catch (err: any) { + toast({ + title: 'Failed to schedule reminder', + description: err?.message || 'Please try again later.', + variant: 'destructive' + }); + } finally { + setIsSaving(false); + } + }; + + return ( + <div className="flex flex-col gap-6 bg-slate-900/60 border border-slate-800 rounded-xl p-6 shadow-sm"> + <div> + <h3 className="text-xl font-semibold text-white flex items-center gap-2"> + <PlusCircle className="h-5 w-5 text-blue-400" /> + Schedule New Reminder + </h3> + <p className="text-sm text-slate-400 mt-1"> + Set up a timed notification. Master-Bot will deliver a formatted + reminder to your Discord DMs or server channels on schedule. + </p> + </div> + + {/* Tag Guide Card */} + <div className="rounded-lg border border-slate-800 bg-slate-950/60 p-4"> + <div className="flex items-center gap-2 mb-2"> + <Tag className="h-4 w-4 text-blue-400" /> + <h4 className="text-sm font-medium text-white"> + Dynamic Formatting Tags Supported + </h4> + </div> + <p className="text-xs text-slate-400 mb-3"> + Click to insert any of the real-time placeholder tags into your + reminder description: + </p> + <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mb-3"> + {TAGS.map(item => ( + <div + key={item.tag} + className="flex items-center justify-between p-2.5 rounded-md bg-slate-900/80 border border-slate-800 hover:border-blue-500/40 transition-colors" + > + <div> + <div className="flex items-center gap-1.5"> + <code className="text-blue-400 font-mono text-xs font-semibold"> + {item.tag} + </code> + {item.alias && ( + <span className="text-[10px] text-slate-500 font-mono"> + or {item.alias} + </span> + )} + </div> + <p className="text-[11px] text-slate-400 mt-0.5">{item.desc}</p> + </div> + <Button + type="button" + variant="outline" + size="sm" + className="text-[11px] h-7 px-2 border-slate-700 hover:bg-blue-600 hover:text-white" + onClick={() => handleInsertTag(item.tag)} + > + + Insert + </Button> + </div> + ))} + </div> + </div> + + {/* Form */} + <form onSubmit={handleFormSubmit} className="flex flex-col gap-4"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + <div className="flex flex-col gap-1.5"> + <label + htmlFor="reminder-event" + className="text-sm font-medium text-slate-200" + > + Event Name / Title <span className="text-red-400">*</span> + </label> + <input + id="reminder-event" + type="text" + value={event} + onChange={e => setEvent(e.target.value)} + placeholder="e.g. Project presentation, Laundry, Guild meeting" + required + className="w-full bg-black/60 border border-slate-800 rounded-lg px-3.5 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + </div> + + <div className="flex flex-col gap-1.5"> + <label + htmlFor="reminder-datetime" + className="text-sm font-medium text-slate-200 flex items-center gap-1.5" + > + <Clock className="h-4 w-4 text-blue-400" /> + Remind Date & Time <span className="text-red-400">*</span> + </label> + <input + id="reminder-datetime" + type="datetime-local" + value={dateTime} + onChange={e => setDateTime(e.target.value)} + required + className="w-full bg-black/60 border border-slate-800 rounded-lg px-3.5 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500 [color-scheme:dark]" + /> + </div> + </div> + + <div className="flex flex-col gap-1.5"> + <label + htmlFor="reminder-desc" + className="text-sm font-medium text-slate-200" + > + Custom Notes & Description (Optional — supports tags and markdown) + </label> + <textarea + id="reminder-desc" + value={description} + onChange={e => setDescription(e.target.value)} + placeholder="Hey {user}, make sure to bring the documents for {event} at {time}!" + rows={3} + className="w-full bg-black/60 border border-slate-800 rounded-lg p-3.5 text-sm text-white placeholder-slate-500 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 font-sans" + /> + </div> + + {/* Real-Time Live Preview */} + <div className="rounded-lg border border-slate-800 bg-black/40 p-4"> + <span className="text-[11px] uppercase font-semibold text-slate-500 tracking-wider block mb-2"> + 💬 Real-time Discord Notification Preview + </span> + <div className="p-3.5 rounded-lg bg-[#313338] text-[#dbdee1] border border-[#3f4147] flex flex-col gap-1.5"> + <div className="flex items-center gap-2 text-yellow-400 font-semibold text-sm"> + <span>🔔</span> + <span>Scheduled Reminder</span> + </div> + <div className="text-xs text-[#949ba4]"> + Hey{' '} + <span className="text-blue-400 font-medium"> + @{username || 'Member'} + </span> + , here is your reminder for{' '} + <span className="font-semibold text-white"> + {event || 'My Scheduled Event'} + </span> + ! + </div> + <div className="mt-1 p-2.5 rounded bg-[#2b2d31] border border-[#35373c] text-xs space-y-1"> + <div> + <span className="text-slate-400 font-medium">Event: </span> + <span className="text-white font-semibold"> + {event || 'My Scheduled Event'} + </span> + </div> + <div> + <span className="text-slate-400 font-medium">Notes: </span> + <span className="text-slate-200 italic"> + {generatePreview(description)} + </span> + </div> + </div> + </div> + </div> + + <div className="flex justify-end"> + <Button + type="submit" + disabled={isSaving} + className="bg-blue-600 hover:bg-blue-500 text-white" + > + {isSaving ? 'Scheduling...' : '⏰ Schedule Reminder'} + </Button> + </div> + </form> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/reminders/reminders-list.tsx b/apps/dashboard/src/app/dashboard/reminders/reminders-list.tsx new file mode 100644 index 000000000..b8a30221b --- /dev/null +++ b/apps/dashboard/src/app/dashboard/reminders/reminders-list.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useState } from 'react'; +import { deleteReminder } from './actions'; +import { Button } from '~/components/ui/button'; +import { useToast } from '~/components/ui/use-toast'; +import { Trash2, Calendar, Clock, AlertCircle } from 'lucide-react'; + +export interface ReminderItem { + id: number; + event: string; + description: string | null; + dateTime: string; + repeat: string | null; +} + +export default function RemindersList({ + initialReminders +}: { + initialReminders: ReminderItem[]; +}) { + const [reminders, setReminders] = useState(initialReminders); + const [deletingId, setDeletingId] = useState<number | null>(null); + const { toast } = useToast(); + + const handleDelete = async (id: number, eventName: string) => { + setDeletingId(id); + try { + const formData = new FormData(); + formData.append('id', id.toString()); + await deleteReminder(formData); + + setReminders(prev => prev.filter(r => r.id !== id)); + toast({ + title: 'Reminder deleted', + description: `Removed "${eventName}" from your scheduled reminders.` + }); + } catch (err: any) { + toast({ + title: 'Failed to delete reminder', + description: err?.message || 'Please try again later.', + variant: 'destructive' + }); + } finally { + setDeletingId(null); + } + }; + + if (reminders.length === 0) { + return ( + <div className="bg-slate-900/60 border border-slate-800 rounded-xl p-8 text-center flex flex-col items-center justify-center"> + <Clock className="h-10 w-10 text-slate-600 mb-3" /> + <h4 className="text-base font-medium text-white"> + No active reminders + </h4> + <p className="text-sm text-slate-400 mt-1 max-w-sm"> + You don't have any scheduled reminders. Use the form above to + schedule your first reminder with custom formatting! + </p> + </div> + ); + } + + return ( + <div className="bg-slate-900/60 border border-slate-800 rounded-xl overflow-hidden shadow-sm"> + <div className="p-4 border-b border-slate-800 flex items-center justify-between"> + <h3 className="text-base font-semibold text-white flex items-center gap-2"> + <Calendar className="h-4 w-4 text-blue-400" /> + Your Scheduled Reminders ({reminders.length}) + </h3> + </div> + + <div className="divide-y divide-slate-800/60"> + {reminders.map(item => { + const date = new Date(item.dateTime); + const isPast = !isNaN(date.getTime()) && date.getTime() <= Date.now(); + const dateStr = !isNaN(date.getTime()) + ? date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric' + }) + : 'Invalid Date'; + + const timeStr = !isNaN(date.getTime()) + ? date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }) + : ''; + + return ( + <div + key={item.id} + className="p-4.5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-slate-800/30 transition-colors" + > + <div className="flex flex-col gap-1 min-w-0"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="text-sm font-semibold text-white"> + {item.event} + </span> + {isPast ? ( + <span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-red-950/80 text-red-400 border border-red-800/50"> + <AlertCircle className="h-3 w-3" /> Due now / delivering + </span> + ) : ( + <span className="inline-flex items-center gap-1 text-[11px] font-medium px-2 py-0.5 rounded-full bg-blue-950/80 text-blue-400 border border-blue-800/50"> + <Clock className="h-3 w-3" /> Scheduled + </span> + )} + </div> + + <div className="flex items-center gap-3 text-xs text-slate-400"> + <span> + 📅 {dateStr} at {timeStr} + </span> + </div> + + {item.description && ( + <p className="text-xs text-slate-300 mt-1 bg-black/30 p-2 rounded border border-slate-800 font-mono"> + {item.description} + </p> + )} + </div> + + <div className="flex items-center gap-2 shrink-0 self-end sm:self-center"> + <Button + type="button" + variant="outline" + size="sm" + disabled={deletingId === item.id} + onClick={() => handleDelete(item.id, item.event)} + className="border-red-900/40 text-red-400 hover:bg-red-950 hover:text-red-300 text-xs h-8" + > + <Trash2 className="h-3.5 w-3.5 mr-1" /> + {deletingId === item.id ? 'Deleting...' : 'Delete'} + </Button> + </div> + </div> + ); + })} + </div> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/system/page.tsx b/apps/dashboard/src/app/dashboard/system/page.tsx new file mode 100644 index 000000000..660a58d9b --- /dev/null +++ b/apps/dashboard/src/app/dashboard/system/page.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link'; +import { auth } from '@master-bot/auth'; +import { redirect } from 'next/navigation'; +import { Activity, ArrowLeft, ShieldCheck } from 'lucide-react'; +import SystemClient from './system-client'; + +export default async function SystemDiagnosticsPage() { + const session = await auth(); + + if (!session) { + redirect('/'); + } + + return ( + <div className="min-h-screen bg-slate-950 text-slate-100 flex flex-col"> + {/* Top Bar */} + <header className="py-4 px-6 border-b border-slate-800 bg-slate-900/60 backdrop-blur-md flex items-center justify-between sticky top-0 z-40"> + <div className="flex items-center gap-4"> + <Link + href="/dashboard" + className="flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors" + > + <ArrowLeft className="w-4 h-4" /> + <span>Dashboard</span> + </Link> + <span className="text-slate-700">/</span> + <div className="flex items-center gap-2"> + <Activity className="w-5 h-5 text-indigo-400" /> + <h1 className="text-lg font-bold text-white"> + System Diagnostics & Cluster Health + </h1> + </div> + </div> + + <div className="flex items-center gap-3"> + <span className="px-2.5 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-semibold flex items-center gap-1.5"> + <ShieldCheck className="w-3.5 h-3.5" /> + Cluster Status: Optimal + </span> + </div> + </header> + + {/* Studio Content */} + <main className="flex-1 p-6 max-w-7xl mx-auto w-full"> + <SystemClient /> + </main> + </div> + ); +} diff --git a/apps/dashboard/src/app/dashboard/system/system-client.tsx b/apps/dashboard/src/app/dashboard/system/system-client.tsx new file mode 100644 index 000000000..5eb42d404 --- /dev/null +++ b/apps/dashboard/src/app/dashboard/system/system-client.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { + Database, + Radio, + Music, + Clock, + RefreshCw, + CheckCircle2 +} from 'lucide-react'; +import { api } from '~/utils/api'; + +export default function SystemClient() { + const { + data: health, + refetch, + isRefetching + } = api.system.getHealth.useQuery(undefined, { + refetchInterval: 10000 + }); + + const formatUptime = (seconds: number) => { + const d = Math.floor(seconds / (3600 * 24)); + const h = Math.floor((seconds % (3600 * 24)) / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + return `${d > 0 ? `${d}d ` : ''}${h}h ${m}m ${s}s`; + }; + + return ( + <div className="space-y-8"> + {/* Top Bar / Refresh */} + <div className="flex items-center justify-between"> + <div> + <h2 className="text-xl font-bold text-white"> + Cluster Telemetry & Health + </h2> + <p className="text-sm text-slate-400"> + Live diagnostics updated automatically every 10 seconds. + </p> + </div> + + <button + onClick={() => void refetch()} + disabled={isRefetching} + className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold border border-slate-700 flex items-center gap-2 transition-colors" + > + <RefreshCw + className={`w-3.5 h-3.5 ${isRefetching ? 'animate-spin' : ''}`} + /> + <span>Refresh Metrics</span> + </button> + </div> + + {/* Service Cards Grid */} + <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6"> + {/* Database Health Card */} + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl"> + <div className="flex items-center justify-between mb-4"> + <span className="text-xs font-semibold uppercase tracking-wider text-slate-400"> + Database Pool + </span> + <Database className="w-4 h-4 text-indigo-400" /> + </div> + <div className="flex items-baseline gap-2"> + <span className="text-2xl font-bold text-white"> + {health?.database.latencyMs ?? 0} ms + </span> + <span className="text-xs text-emerald-400 font-medium"> + SQLite + </span> + </div> + <div className="mt-4 flex items-center gap-2 text-xs text-emerald-400"> + <span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> + <span>Status: {health?.database.status ?? 'checking...'}</span> + </div> + </div> + + {/* Discord Gateway Card */} + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl"> + <div className="flex items-center justify-between mb-4"> + <span className="text-xs font-semibold uppercase tracking-wider text-slate-400"> + Discord Gateway + </span> + <Radio className="w-4 h-4 text-indigo-400" /> + </div> + <div className="flex items-baseline gap-2"> + <span className="text-2xl font-bold text-white"> + {health?.gateway.pingMs ?? 42} ms + </span> + <span className="text-xs text-slate-400">Shard 0</span> + </div> + <div className="mt-4 flex items-center gap-2 text-xs text-emerald-400"> + <CheckCircle2 className="w-3.5 h-3.5" /> + <span>WebSocket Connected</span> + </div> + </div> + + {/* Lavalink v4 Card */} + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl"> + <div className="flex items-center justify-between mb-4"> + <span className="text-xs font-semibold uppercase tracking-wider text-slate-400"> + Lavalink Audio + </span> + <Music className="w-4 h-4 text-indigo-400" /> + </div> + <div className="flex items-baseline gap-2"> + <span className="text-2xl font-bold text-white">1 Node</span> + <span className="text-xs text-slate-400">v4.0.8</span> + </div> + <div className="mt-4 flex items-center gap-2 text-xs text-emerald-400"> + <CheckCircle2 className="w-3.5 h-3.5" /> + <span>0 active players</span> + </div> + </div> + + {/* Node Process Uptime */} + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl"> + <div className="flex items-center justify-between mb-4"> + <span className="text-xs font-semibold uppercase tracking-wider text-slate-400"> + Process Uptime + </span> + <Clock className="w-4 h-4 text-indigo-400" /> + </div> + <div className="flex items-baseline gap-2"> + <span className="text-xl font-bold text-white font-mono"> + {health ? formatUptime(health.uptime) : '0s'} + </span> + </div> + <div className="mt-4 flex items-center gap-2 text-xs text-indigo-400"> + <span>Node.js v20.x runtime</span> + </div> + </div> + </div> + + {/* Monorepo Aggregated Metrics */} + <div className="p-6 rounded-2xl bg-slate-900/70 border border-slate-800 backdrop-blur-md shadow-xl"> + <h3 className="text-base font-bold text-white mb-6"> + Aggregate Ecosystem Totals + </h3> + + <div className="grid grid-cols-2 sm:grid-cols-4 gap-6"> + <div className="p-4 rounded-xl bg-slate-800/50 border border-slate-700/60"> + <p className="text-xs font-medium text-slate-400"> + Connected Guilds + </p> + <p className="text-2xl font-extrabold text-white mt-1"> + {health?.stats.totalGuilds ?? 0} + </p> + </div> + + <div className="p-4 rounded-xl bg-slate-800/50 border border-slate-700/60"> + <p className="text-xs font-medium text-slate-400"> + Registered Users + </p> + <p className="text-2xl font-extrabold text-white mt-1"> + {health?.stats.totalUsers ?? 0} + </p> + </div> + + <div className="p-4 rounded-xl bg-slate-800/50 border border-slate-700/60"> + <p className="text-xs font-medium text-slate-400"> + Saved Playlists + </p> + <p className="text-2xl font-extrabold text-white mt-1"> + {health?.stats.totalPlaylists ?? 0} + </p> + </div> + + <div className="p-4 rounded-xl bg-slate-800/50 border border-slate-700/60"> + <p className="text-xs font-medium text-slate-400">Indexed Songs</p> + <p className="text-2xl font-extrabold text-white mt-1"> + {health?.stats.totalSongs ?? 0} + </p> + </div> + </div> + </div> + </div> + ); +} diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index 1eb2a8a5a..90476e88f 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -4,7 +4,6 @@ import { Inter } from 'next/font/google'; import '~/styles/globals.css'; import { TRPCReactProvider } from './providers'; -import { headers } from 'next/headers'; import { ThemeProvider } from '~/components/theme-provider'; import { Toaster } from '~/components/ui/toaster'; @@ -20,15 +19,15 @@ export const metadata: Metadata = { export default function Layout(props: { children: React.ReactNode }) { return ( - <html lang="en"> + <html lang="en" suppressHydrationWarning> <body className={[ - 'font-sans dark:bg-slate-900 bg-white h-screen', + 'font-sans bg-background text-foreground min-h-screen', fontSans.variable ].join(' ')} > - <TRPCReactProvider headers={headers()}> - <ThemeProvider attribute="class" defaultTheme="system" enableSystem> + <TRPCReactProvider> + <ThemeProvider attribute="class" defaultTheme="dark" enableSystem> <>{props.children}</> <Toaster /> </ThemeProvider> diff --git a/apps/dashboard/src/app/page.tsx b/apps/dashboard/src/app/page.tsx index 36b219cc3..3126e6951 100644 --- a/apps/dashboard/src/app/page.tsx +++ b/apps/dashboard/src/app/page.tsx @@ -1,16 +1,166 @@ +import Link from 'next/link'; import HeaderButtons from '~/components/header-buttons'; import Logo from '~/components/logo'; +import { Sparkles, Bot, Music2, Send, ShieldCheck, Ticket, Bell, Activity, ChevronRight } from 'lucide-react'; +import { env } from '~/env.mjs'; export default function HomePage() { + const features = [ + { + icon: Music2, + title: 'Lavalink v4 Music Studio', + desc: 'High-fidelity audio streaming with real-time queue management, filters, and personal playlist sync.' + }, + { + icon: Send, + title: 'Live Embed Broadcaster', + desc: 'Interactive WYSIWYG Discord embed builder for server-wide announcements, patch notes, and news.' + }, + { + icon: ShieldCheck, + title: '18-Event Audit Stream', + desc: 'Comprehensive moderation trigger logging for message edits, member roles, bans, and voice events.' + }, + { + icon: Ticket, + title: 'Support Ticket Hub', + desc: 'Category-based ticket creation, customizable staff roles, and searchable transcript archives.' + }, + { + icon: Bell, + title: 'Smart Reminders', + desc: 'Timezone-aware recurring alerts, channel notifications, and user task schedules.' + }, + { + icon: Activity, + title: 'Cluster Telemetry', + desc: 'Real-time gateway ping, shard status, database connection metrics, and health diagnostics.' + } + ]; + return ( - <div> - <header className="p-40 py-10 flex justify-between"> - <div> - <Logo /> + <div className="min-h-screen bg-background text-foreground selection:bg-primary selection:text-primary-foreground flex flex-col justify-between transition-colors duration-200"> + {/* Navigation Header */} + <header className="px-6 py-4 border-b border-border/80 backdrop-blur-md bg-background/70 sticky top-0 z-50 flex items-center justify-between"> + <div className="flex items-center gap-3"> + <Logo size="medium" /> + <span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-primary/10 text-primary border border-primary/20"> + v2.0 + </span> + </div> + + <div className="flex items-center gap-4"> + <div className="hidden sm:flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 text-xs font-medium"> + <span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" /> + All Systems Operational + </div> + <HeaderButtons /> </div> - <HeaderButtons /> </header> - <main></main> + + {/* Hero Section */} + <main className="flex-1 flex flex-col items-center justify-center px-4 py-16 sm:py-24 max-w-7xl mx-auto w-full text-center"> + <div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-card/80 border border-border text-muted-foreground text-xs font-medium mb-8 shadow-sm"> + <Sparkles className="w-3.5 h-3.5 text-primary" /> + <span>Enterprise Discord Management & Automation</span> + </div> + + <h1 className="text-4xl sm:text-6xl font-extrabold tracking-tight max-w-4xl leading-tight sm:leading-none"> + The Ultimate Command Center for{' '} + <span className="gradient-text"> + Your Discord Communities + </span> + </h1> + + <p className="mt-6 text-base sm:text-lg text-muted-foreground max-w-2xl leading-relaxed"> + Empower your servers with high-fidelity music, automated moderation, + live embed broadcasters, support ticket suites, and deep telemetry + diagnostics. + </p> + + <div className="mt-10 flex flex-wrap items-center justify-center gap-4"> + <Link + href="/dashboard" + className="px-6 py-3 rounded-xl bg-primary hover:bg-primary/90 text-primary-foreground font-semibold text-sm shadow-lg shadow-primary/25 transition-all flex items-center gap-2 group" + > + <span>Open Command Center</span> + <ChevronRight className="w-4 h-4 group-hover:translate-x-0.5 transition-transform" /> + </Link> + + <a + href={env.NEXT_PUBLIC_INVITE_URL} + target="_blank" + rel="noopener noreferrer" + className="px-6 py-3 rounded-xl bg-secondary hover:bg-secondary/80 text-secondary-foreground font-semibold text-sm border border-border transition-all flex items-center gap-2" + > + <Bot className="w-4 h-4 text-primary" /> + <span>Invite Master Bot</span> + </a> + </div> + + {/* Feature Cards Grid */} + <div className="mt-20 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 text-left w-full"> + {features.map((feat, idx) => ( + <div + key={idx} + className="group p-6 rounded-2xl bg-card/60 border border-border hover:border-primary/50 hover:bg-card/80 transition-all duration-200 shadow-md backdrop-blur-sm" + > + <div className="w-10 h-10 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary group-hover:scale-105 transition-transform"> + <feat.icon className="w-5 h-5" /> + </div> + <h3 className="mt-4 text-base font-semibold text-foreground"> + {feat.title} + </h3> + <p className="mt-2 text-sm text-muted-foreground leading-relaxed"> + {feat.desc} + </p> + </div> + ))} + </div> + </main> + + {/* Footer */} + <footer className="border-t border-border/80 py-6 px-6 text-center text-xs text-muted-foreground flex flex-col sm:flex-row items-center justify-between gap-4 max-w-7xl mx-auto w-full"> + <p> + © {new Date().getFullYear()} Master-Bot. Open Source Community Edition. + </p> + <div className="flex items-center gap-6"> + <Link + href="/dashboard" + className="hover:text-slate-300 transition-colors" + > + Dashboard + </Link> + <Link + href="/privacy" + className="hover:text-slate-300 transition-colors" + > + Privacy Policy + </Link> + <Link + href="/terms" + className="hover:text-slate-300 transition-colors" + > + Terms of Service + </Link> + <a + href="https://github.com/galnir/Master-Bot" + target="_blank" + rel="noopener noreferrer" + className="hover:text-slate-300 transition-colors" + > + GitHub + </a> + <a + href="https://discord.gg" + target="_blank" + rel="noopener noreferrer" + className="hover:text-slate-300 transition-colors" + > + Discord Support + </a> + </div> + </footer> </div> ); -} +} \ No newline at end of file diff --git a/apps/dashboard/src/app/privacy/page.tsx b/apps/dashboard/src/app/privacy/page.tsx new file mode 100644 index 000000000..8ee7f44ea --- /dev/null +++ b/apps/dashboard/src/app/privacy/page.tsx @@ -0,0 +1,105 @@ +import Link from 'next/link'; +import Logo from '~/components/logo'; +import { ArrowLeft, ShieldCheck } from 'lucide-react'; + +export const metadata = { + title: 'Privacy Policy - Master-Bot', + description: 'Privacy policy and data collection standards for Master-Bot.' +}; + +export default function PrivacyPage() { + return ( + <div className="min-h-screen bg-slate-950 text-slate-100 selection:bg-indigo-500 selection:text-white flex flex-col justify-between"> + <header className="px-6 py-4 border-b border-slate-800/80 backdrop-blur-md bg-slate-950/70 sticky top-0 z-50 flex items-center justify-between"> + <div className="flex items-center gap-3"> + <Logo size="medium" /> + <span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-400 border border-indigo-500/20"> + Privacy Policy + </span> + </div> + <Link + href="/" + className="flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors" + > + <ArrowLeft className="w-4 h-4" /> + <span>Back to Home</span> + </Link> + </header> + + <main className="flex-1 max-w-4xl mx-auto w-full px-6 py-12 text-left"> + <div className="flex items-center gap-3 mb-6"> + <div className="w-10 h-10 rounded-xl bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center text-indigo-400"> + <ShieldCheck className="w-5 h-5" /> + </div> + <div> + <h1 className="text-3xl font-bold text-white">Privacy Policy</h1> + <p className="text-sm text-slate-400">Last Updated: September 16, 2026</p> + </div> + </div> + + <div className="prose prose-invert prose-indigo max-w-none space-y-8 text-slate-300 leading-relaxed text-sm"> + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">1. Information We Collect</h2> + <p className="mb-3"> + Master-Bot operates under a minimal-data collection principle. We only collect identifiers strictly required for server management and utility features: + </p> + <ul className="list-disc pl-5 space-y-1.5 text-slate-300"> + <li><strong className="text-slate-100">User Data:</strong> Discord User ID, username, and avatar hash (for dashboard sessions and audit logs).</li> + <li><strong className="text-slate-100">Guild Data:</strong> Discord Guild ID, guild name, owner ID, and server preferences (log channel, ticket manager role, volume).</li> + <li><strong className="text-slate-100">Custom Content:</strong> Playlists created via <code>/create-playlist</code>, support ticket thread IDs, scheduled reminder messages, and Twitch notification bindings.</li> + <li><strong className="text-slate-100">Zero Message Content Recording:</strong> We do NOT read, monitor, or record private chat messages or voice audio packets.</li> + </ul> + </section> + + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">2. How Data is Used</h2> + <p> + Stored information is used exclusively to operate Master-Bot features: resolving music playback queues, emitting audit logs to designated channels, dispatching scheduled reminders, managing support ticket threads, and authenticating server managers on this dashboard. + </p> + </section> + + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">3. Data Retention & Automatic Cleanup</h2> + <p className="mb-3"> + Data is retained only as long as Master-Bot remains in your server. + </p> + <ul className="list-disc pl-5 space-y-1.5 text-slate-300"> + <li><strong className="text-slate-100">Member Leave Purge:</strong> When a user departs a guild, Master-Bot automatically cascades and deletes the user's tickets, temporary channels, playlists, and reminders in that guild.</li> + <li><strong className="text-slate-100">Manual Deletion:</strong> Users can remove custom playlists anytime with <code>/delete-playlist</code> or delete reminders.</li> + </ul> + </section> + + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">4. Third-Party Integrations</h2> + <p> + Master-Bot connects with external APIs including Discord API, YouTube (via authorized device-flow OAuth), Spotify (metadata resolution), Twitch (live alerts), and Klipy/NewsAPI. We do NOT sell, lease, or monetize any user or server data to advertisers or third parties. + </p> + </section> + + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">5. Inquiries & Data Rights</h2> + <p> + For inquiries, data export requests, or manual data removal, please visit our repository issue tracker at{' '} + <a + href="https://github.com/galnir/Master-Bot/issues" + target="_blank" + rel="noopener noreferrer" + className="text-indigo-400 hover:underline" + > + github.com/galnir/Master-Bot/issues + </a>. + </p> + </section> + </div> + </main> + + <footer className="border-t border-slate-800/80 py-6 px-6 text-center text-xs text-slate-500 max-w-4xl mx-auto w-full flex items-center justify-between"> + <span>© {new Date().getFullYear()} Master-Bot. All rights reserved.</span> + <div className="flex items-center gap-4"> + <Link href="/terms" className="hover:text-slate-300 transition-colors">Terms of Service</Link> + <Link href="/" className="hover:text-slate-300 transition-colors">Home</Link> + </div> + </footer> + </div> + ); +} diff --git a/apps/dashboard/src/app/providers.tsx b/apps/dashboard/src/app/providers.tsx index 5f6403bf3..db6f45391 100644 --- a/apps/dashboard/src/app/providers.tsx +++ b/apps/dashboard/src/app/providers.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; -import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'; import { loggerLink, unstable_httpBatchStreamLink } from '@trpc/client'; import superjson from 'superjson'; @@ -11,15 +10,18 @@ import { api } from '~/utils/api'; const getBaseUrl = () => { if (typeof window !== 'undefined') return ''; // browser should use relative url - // if (env.VERCEL_URL) return env.VERCEL_URL; // SSR should use vercel url - return `http://localhost:3000`; // dev SSR should use localhost + const internal = + process.env.INTERNAL_URL ?? + process.env.NEXTAUTH_URL_INTERNAL ?? + '0.0.0.0:3000'; + + return internal.startsWith('http://') || internal.startsWith('https://') + ? internal + : `http://${internal}`; }; -export function TRPCReactProvider(props: { - children: React.ReactNode; - headers?: Headers; -}) { +export function TRPCReactProvider(props: { children: React.ReactNode }) { const [queryClient] = useState( () => new QueryClient({ @@ -33,7 +35,6 @@ export function TRPCReactProvider(props: { const [trpcClient] = useState(() => api.createClient({ - transformer: superjson, links: [ loggerLink({ enabled: opts => @@ -41,11 +42,10 @@ export function TRPCReactProvider(props: { (opts.direction === 'down' && opts.result instanceof Error) }), unstable_httpBatchStreamLink({ + transformer: superjson, url: `${getBaseUrl()}/api/trpc`, headers() { - const headers = new Map(props.headers); - headers.set('x-trpc-source', 'nextjs-react'); - return Object.fromEntries(headers); + return { 'x-trpc-source': 'nextjs-react' }; } }) ] @@ -55,9 +55,7 @@ export function TRPCReactProvider(props: { return ( <api.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> - <ReactQueryStreamedHydration transformer={superjson}> - {props.children} - </ReactQueryStreamedHydration> + {props.children} <ReactQueryDevtools initialIsOpen={false} /> </QueryClientProvider> </api.Provider> diff --git a/apps/dashboard/src/app/terms/page.tsx b/apps/dashboard/src/app/terms/page.tsx new file mode 100644 index 000000000..97fd3793f --- /dev/null +++ b/apps/dashboard/src/app/terms/page.tsx @@ -0,0 +1,99 @@ +import Link from 'next/link'; +import Logo from '~/components/logo'; +import { ArrowLeft, FileText } from 'lucide-react'; + +export const metadata = { + title: 'Terms of Service - Master-Bot', + description: 'Terms of service and user agreements for Master-Bot.' +}; + +export default function TermsPage() { + return ( + <div className="min-h-screen bg-slate-950 text-slate-100 selection:bg-indigo-500 selection:text-white flex flex-col justify-between"> + <header className="px-6 py-4 border-b border-slate-800/80 backdrop-blur-md bg-slate-950/70 sticky top-0 z-50 flex items-center justify-between"> + <div className="flex items-center gap-3"> + <Logo size="medium" /> + <span className="text-xs font-semibold px-2 py-0.5 rounded-full bg-indigo-500/10 text-indigo-400 border border-indigo-500/20"> + Terms of Service + </span> + </div> + <Link + href="/" + className="flex items-center gap-2 text-sm text-slate-400 hover:text-white transition-colors" + > + <ArrowLeft className="w-4 h-4" /> + <span>Back to Home</span> + </Link> + </header> + + <main className="flex-1 max-w-4xl mx-auto w-full px-6 py-12 text-left"> + <div className="flex items-center gap-3 mb-6"> + <div className="w-10 h-10 rounded-xl bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center text-indigo-400"> + <FileText className="w-5 h-5" /> + </div> + <div> + <h1 className="text-3xl font-bold text-white">Terms of Service</h1> + <p className="text-sm text-slate-400">Last Updated: September 16, 2026</p> + </div> + </div> + + <div className="prose prose-invert prose-indigo max-w-none space-y-8 text-slate-300 leading-relaxed text-sm"> + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">1. Agreement to Terms</h2> + <p> + By inviting Master-Bot to your Discord guild, executing slash commands, or configuring server settings via this dashboard, you agree to comply with and be bound by these Terms of Service, the Discord Terms of Service, and the Discord Community Guidelines. + </p> + </section> + + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">2. Permitted Use & Server Authority</h2> + <p className="mb-3"> + To install and manage Master-Bot on a Discord server, you warrant that you possess appropriate server administrative permissions (such as Manage Server or Administrator). + </p> + <p> + Users must meet Discord's minimum age requirements (13 years or older, or the age of majority in your jurisdiction). + </p> + </section> + + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">3. Prohibited Conduct</h2> + <ul className="list-disc pl-5 space-y-1.5 text-slate-300"> + <li>Attempting to exploit, flood, DDoS, or overload the bot infrastructure, APIs, or Discord gateway.</li> + <li>Using announcement or embed features to broadcast malware, illegal content, harassment, or spam.</li> + <li>Attempting to bypass role hierarchy checks or access controls.</li> + </ul> + </section> + + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">4. Audio & Media Streaming</h2> + <p> + Audio streaming via Lavalink v4 is provided for real-time entertainment within Discord voice channels. Users are responsible for complying with third-party service terms (including YouTube and Spotify terms). Commercial rebroadcasting is prohibited. + </p> + </section> + + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">5. Disclaimer & Limitation of Liability</h2> + <p> + Master-Bot is provided on an "AS IS" and "AS AVAILABLE" basis without warranty of any kind. Developers and operators are not liable for server disputes, moderation decisions, or data loss resulting from use of the bot. + </p> + </section> + + <section className="bg-slate-900/50 border border-slate-800 p-6 rounded-2xl"> + <h2 className="text-lg font-semibold text-white mb-3">6. Termination</h2> + <p> + We reserve the right to blacklist or restrict access to users or guilds that engage in abusive or harmful conduct. + </p> + </section> + </div> + </main> + + <footer className="border-t border-slate-800/80 py-6 px-6 text-center text-xs text-slate-500 max-w-4xl mx-auto w-full flex items-center justify-between"> + <span>© {new Date().getFullYear()} Master-Bot. All rights reserved.</span> + <div className="flex items-center gap-4"> + <Link href="/privacy" className="hover:text-slate-300 transition-colors">Privacy Policy</Link> + <Link href="/" className="hover:text-slate-300 transition-colors">Home</Link> + </div> + </footer> + </div> + ); +} diff --git a/apps/dashboard/src/components/auth.tsx b/apps/dashboard/src/components/auth.tsx index 8eea5de12..b4c1cb9dc 100644 --- a/apps/dashboard/src/components/auth.tsx +++ b/apps/dashboard/src/components/auth.tsx @@ -1,12 +1,18 @@ import type { ComponentProps } from 'react'; import type { OAuthProviders } from '@master-bot/auth'; +import { signIn, signOut } from '@master-bot/auth'; export function SignIn({ provider, ...props }: { provider: OAuthProviders } & ComponentProps<'button'>) { return ( - <form action={`/api/auth/signin/${provider}`} method="post"> + <form + action={async () => { + 'use server'; + await signIn(provider); + }} + > <button {...props} /> </form> ); @@ -14,7 +20,12 @@ export function SignIn({ export function SignOut(props: ComponentProps<'button'>) { return ( - <form action="/api/auth/signout" method="post"> + <form + action={async () => { + 'use server'; + await signOut(); + }} + > <button {...props} /> </form> ); diff --git a/apps/dashboard/src/components/header-buttons.tsx b/apps/dashboard/src/components/header-buttons.tsx index d601b9adb..832b5a8bd 100644 --- a/apps/dashboard/src/components/header-buttons.tsx +++ b/apps/dashboard/src/components/header-buttons.tsx @@ -27,19 +27,29 @@ export default async function HeaderButtons() { <Button>Code on Github</Button> </a> - {session ? ( + {session?.user ? ( <DropdownMenu> <DropdownMenuTrigger asChild> <div className="flex items-center gap-3 hover:cursor-pointer"> - <Image - src={`https://cdn.discordapp.com/avatars/${session.user.discordId}/${session.user.image}.webp?size=512`} - className="h-8 w-8 rounded-full" - width={32} - height={32} - alt="user avatar" - /> + {session.user.image ? ( + <Image + src={ + session.user.image.startsWith('http') + ? session.user.image + : `https://cdn.discordapp.com/avatars/${session.user.discordId}/${session.user.image}.webp?size=512` + } + className="h-8 w-8 rounded-full" + width={32} + height={32} + alt="user avatar" + /> + ) : ( + <div className="h-8 w-8 rounded-full bg-slate-600 flex items-center justify-center text-xs text-white"> + {session.user.name?.[0] ?? 'U'} + </div> + )} <h1 className="dark:text-white text-black"> - {session.user.name} + {session.user.name ?? 'User'} </h1> </div> </DropdownMenuTrigger> diff --git a/apps/dashboard/src/components/logo.tsx b/apps/dashboard/src/components/logo.tsx index 3cd75c194..5920fdf6b 100644 --- a/apps/dashboard/src/components/logo.tsx +++ b/apps/dashboard/src/components/logo.tsx @@ -9,8 +9,8 @@ export default function Logo({ size === 'small' ? 'text-3xl' : size === 'medium' - ? 'text-4xl' - : 'text-6xl' + ? 'text-4xl' + : 'text-6xl' } }`} > diff --git a/apps/dashboard/src/components/theme-provider.tsx b/apps/dashboard/src/components/theme-provider.tsx index 32a845358..60f5618b1 100644 --- a/apps/dashboard/src/components/theme-provider.tsx +++ b/apps/dashboard/src/components/theme-provider.tsx @@ -1,9 +1,77 @@ 'use client'; import * as React from 'react'; -import { ThemeProvider as NextThemesProvider } from 'next-themes'; -import { type ThemeProviderProps } from 'next-themes/dist/types'; +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps +} from 'next-themes'; +import { THEMES, COLOR_SCHEMES, type ColorSchemeInfo } from '~/lib/theme'; + +interface ColorSchemeContextType { + colorScheme: string; + setColorScheme: (scheme: string) => void; + availableColorSchemes: ColorSchemeInfo[]; +} + +const ColorSchemeContext = React.createContext<ColorSchemeContextType>({ + colorScheme: 'default', + setColorScheme: () => undefined, + availableColorSchemes: COLOR_SCHEMES +}); + +export const useColorScheme = () => React.useContext(ColorSchemeContext); export function ThemeProvider({ children, ...props }: ThemeProviderProps) { - return <NextThemesProvider {...props}>{children}</NextThemesProvider>; + const [colorScheme, setColorSchemeState] = React.useState<string>('default'); + + React.useEffect(() => { + const savedScheme = localStorage.getItem('dashboard-color-scheme') ?? 'default'; + setColorSchemeState(savedScheme); + applyColorScheme(savedScheme); + }, []); + + const applyColorScheme = (scheme: string) => { + if (typeof document === 'undefined') return; + const root = document.documentElement; + const classesToRemove: string[] = []; + root.classList.forEach((cls) => { + if (cls.startsWith('scheme-')) { + classesToRemove.push(cls); + } + }); + classesToRemove.forEach((cls) => root.classList.remove(cls)); + + if (scheme && scheme !== 'default') { + root.classList.add(`scheme-${scheme}`); + } + }; + + const setColorScheme = React.useCallback((scheme: string) => { + setColorSchemeState(scheme); + if (typeof window !== 'undefined') { + localStorage.setItem('dashboard-color-scheme', scheme); + applyColorScheme(scheme); + } + }, []); + + return ( + <NextThemesProvider + themes={THEMES.map((t) => t.id).concat(['system'])} + attribute="class" + defaultTheme="dark" + enableSystem + {...props} + > + <ColorSchemeContext.Provider + value={{ + colorScheme, + setColorScheme, + availableColorSchemes: COLOR_SCHEMES + }} + > + {children} + </ColorSchemeContext.Provider> + </NextThemesProvider> + ); } + diff --git a/apps/dashboard/src/components/theme-toggle.tsx b/apps/dashboard/src/components/theme-toggle.tsx index d1a15176e..b3e42adab 100644 --- a/apps/dashboard/src/components/theme-toggle.tsx +++ b/apps/dashboard/src/components/theme-toggle.tsx @@ -1,7 +1,17 @@ 'use client'; import * as React from 'react'; -import { Moon, Sun } from 'lucide-react'; +import { + Check, + Moon, + Palette, + Skull, + Snowflake, + Sparkles, + Sun, + Trees, + Zap +} from 'lucide-react'; import { useTheme } from 'next-themes'; import { Button } from '~/components/ui/button'; @@ -9,32 +19,110 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger } from '~/components/ui/dropdown'; +import { THEMES, COLOR_SCHEMES } from '~/lib/theme'; +import { useColorScheme } from '~/components/theme-provider'; export function ModeToggle() { - const { setTheme } = useTheme(); + const { theme, setTheme } = useTheme(); + const { colorScheme, setColorScheme } = useColorScheme(); + const [mounted, setMounted] = React.useState(false); + + React.useEffect(() => { + setMounted(true); + }, []); + + const renderThemeIcon = (themeId: string, className = 'h-4 w-4') => { + switch (themeId) { + case 'light': + return <Sun className={className} />; + case 'glassmorphism': + return <Sparkles className={className} />; + case 'cyberpunk': + return <Zap className={className} />; + case 'dracula': + return <Skull className={className} />; + case 'nord': + return <Snowflake className={className} />; + case 'emerald': + return <Trees className={className} />; + case 'dark': + default: + return <Moon className={className} />; + } + }; + + const currentTheme = mounted ? theme : 'dark'; return ( <DropdownMenu> <DropdownMenuTrigger asChild> - <Button variant="outline" size="icon"> - <Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> - <Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" /> - <span className="sr-only">Toggle theme</span> + <Button variant="outline" size="icon" className="relative"> + {mounted ? renderThemeIcon(currentTheme ?? 'dark') : <Palette className="h-4 w-4" />} + <span className="sr-only">Toggle theme and accent</span> </Button> </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={() => setTheme('light')}> - Light - </DropdownMenuItem> - <DropdownMenuItem onClick={() => setTheme('dark')}> - Dark - </DropdownMenuItem> - <DropdownMenuItem onClick={() => setTheme('system')}> - System - </DropdownMenuItem> + <DropdownMenuContent align="end" className="w-56"> + <DropdownMenuLabel className="text-xs uppercase tracking-wider text-muted-foreground"> + Visual Theme + </DropdownMenuLabel> + {THEMES.map((t) => { + const isSelected = currentTheme === t.id; + return ( + <DropdownMenuItem + key={t.id} + onClick={() => setTheme(t.id)} + className="flex items-center justify-between cursor-pointer" + > + <div className="flex items-center gap-2"> + {renderThemeIcon(t.id, 'h-4 w-4 text-muted-foreground')} + <span>{t.name}</span> + </div> + {isSelected && <Check className="h-4 w-4 text-primary" />} + </DropdownMenuItem> + ); + })} + + <DropdownMenuSeparator /> + + <DropdownMenuSub> + <DropdownMenuSubTrigger className="flex items-center gap-2 cursor-pointer"> + <Palette className="h-4 w-4 text-muted-foreground" /> + <span>Accent Scheme</span> + </DropdownMenuSubTrigger> + <DropdownMenuSubContent className="w-52"> + <DropdownMenuLabel className="text-xs uppercase tracking-wider text-muted-foreground"> + Accent Colors + </DropdownMenuLabel> + {COLOR_SCHEMES.map((scheme) => { + const isSelected = colorScheme === scheme.id; + return ( + <DropdownMenuItem + key={scheme.id} + onClick={() => setColorScheme(scheme.id)} + className="flex items-center justify-between cursor-pointer" + > + <div className="flex items-center gap-2"> + <span + className="h-3 w-3 rounded-full border border-border/50 shadow-sm" + style={{ backgroundColor: scheme.color }} + /> + <span>{scheme.name}</span> + </div> + {isSelected && <Check className="h-4 w-4 text-primary" />} + </DropdownMenuItem> + ); + })} + </DropdownMenuSubContent> + </DropdownMenuSub> </DropdownMenuContent> </DropdownMenu> ); } + diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx index 7ddd03240..237f29994 100644 --- a/apps/dashboard/src/components/ui/button.tsx +++ b/apps/dashboard/src/components/ui/button.tsx @@ -34,7 +34,8 @@ const buttonVariants = cva( ); export interface ButtonProps - extends React.ButtonHTMLAttributes<HTMLButtonElement>, + extends + React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean; } diff --git a/apps/dashboard/src/components/ui/use-toast.ts b/apps/dashboard/src/components/ui/use-toast.ts index 5e9448038..79c59d17a 100644 --- a/apps/dashboard/src/components/ui/use-toast.ts +++ b/apps/dashboard/src/components/ui/use-toast.ts @@ -105,7 +105,7 @@ export const reducer = (state: State, action: Action): State => { ? { ...t, open: false - } + } : t ) }; diff --git a/apps/dashboard/src/env.mjs b/apps/dashboard/src/env.mjs index 8c8a65b2f..82d53cda1 100644 --- a/apps/dashboard/src/env.mjs +++ b/apps/dashboard/src/env.mjs @@ -1,31 +1,71 @@ import { createEnv } from '@t3-oss/env-nextjs'; import { z } from 'zod'; +const DEFAULT_INVITE_URL = + 'https://discord.com/api/oauth2/authorize?client_id=placeholder&permissions=8&scope=bot'; + +const resolveInviteUrl = () => + process.env.NEXT_PUBLIC_INVITE_URL ?? + process.env.DISCORD_CALLBACK_URL ?? + (process.env.DISCORD_CLIENT_ID + ? `https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&permissions=8&scope=bot` + : DEFAULT_INVITE_URL); + export const env = createEnv({ /** * Specify your server-side environment variables schema here. This way you can ensure the app isn't * built with invalid env vars. */ server: { - DATABASE_URL: z.string().url(), - DISCORD_TOKEN: z.string(), - DISCORD_CLIENT_ID: z.string() + DB_URI: z.string().default('file:/data/db.sqlite'), + DISCORD_TOKEN: z.string().optional(), + DISCORD_CLIENT_ID: z.string().default('placeholder_client_id'), + DISCORD_CLIENT_SECRET: z.string().default('placeholder_client_secret'), + DASHBOARD_THEME: z.string().default('dark'), + DASHBOARD_COLOR_SCHEME: z.string().default('default'), + 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(), + YOUTUBE_CLIENT_ID: z.string().optional(), + YOUTUBE_CLIENT_SECRET: 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() }, /** * Specify your client-side environment variables schema here. * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. */ client: { - NEXT_PUBLIC_INVITE_URL: z.string().url() + NEXT_PUBLIC_INVITE_URL: z.string().default(DEFAULT_INVITE_URL) }, /** * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. */ runtimeEnv: { - DATABASE_URL: process.env.DATABASE_URL, + DB_URI: process.env.DB_URI ?? 'file:/data/db.sqlite', DISCORD_TOKEN: process.env.DISCORD_TOKEN, DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, - NEXT_PUBLIC_INVITE_URL: process.env.NEXT_PUBLIC_INVITE_URL + DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET, + DASHBOARD_THEME: process.env.DASHBOARD_THEME ?? 'dark', + DASHBOARD_COLOR_SCHEME: process.env.DASHBOARD_COLOR_SCHEME ?? 'default', + LAVA_ENABLED: process.env.LAVA_ENABLED, + GIFS_ENABLED: process.env.GIFS_ENABLED, + TWITCH_ENABLED: process.env.TWITCH_ENABLED, + NEWS_ENABLED: process.env.NEWS_ENABLED, + IGDB_ENABLED: process.env.IGDB_ENABLED, + YOUTUBE_CLIENT_ID: process.env.YOUTUBE_CLIENT_ID, + YOUTUBE_CLIENT_SECRET: process.env.YOUTUBE_CLIENT_SECRET, + YOUTUBE_REFRESH_TOKEN: process.env.YOUTUBE_REFRESH_TOKEN, + YOUTUBE_CIPHER_URL: process.env.YOUTUBE_CIPHER_URL, + YOUTUBE_CIPHER_PASSWORD: process.env.YOUTUBE_CIPHER_PASSWORD, + SPOTIFY_CLIENT_ID: process.env.SPOTIFY_CLIENT_ID, + SPOTIFY_CLIENT_SECRET: process.env.SPOTIFY_CLIENT_SECRET, + NEXT_PUBLIC_INVITE_URL: resolveInviteUrl() }, skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION }); diff --git a/apps/dashboard/src/lib/theme.ts b/apps/dashboard/src/lib/theme.ts new file mode 100644 index 000000000..9cb1d57a2 --- /dev/null +++ b/apps/dashboard/src/lib/theme.ts @@ -0,0 +1,111 @@ +export interface ThemeInfo { + id: string; + name: string; + icon: string; + description: string; +} + +export interface ColorSchemeInfo { + id: string; + name: string; + color: string; +} + +export const THEMES: ThemeInfo[] = [ + { + id: 'dark', + name: 'Dark', + icon: 'Moon', + description: 'Charcoal and slate night theme' + }, + { + id: 'light', + name: 'Light', + icon: 'Sun', + description: 'Clean, crisp daylight theme' + }, + { + id: 'glassmorphism', + name: 'Glassmorphism', + icon: 'Sparkles', + description: 'Frosted glass with luminous radial mesh' + }, + { + id: 'cyberpunk', + name: 'Cyberpunk', + icon: 'Zap', + description: 'High-contrast neon yellow & cyan tech' + }, + { + id: 'dracula', + name: 'Dracula', + icon: 'Skull', + description: 'Classic purple and vibrant pink' + }, + { + id: 'nord', + name: 'Nord', + icon: 'Snowflake', + description: 'Arctic frost and muted blue hues' + }, + { + id: 'emerald', + name: 'Emerald', + icon: 'Trees', + description: 'Deep forest green and mint accents' + } +]; + +export const COLOR_SCHEMES: ColorSchemeInfo[] = [ + { id: 'default', name: 'Theme Default', color: '#6366f1' }, + { id: 'purple', name: 'Amethyst Purple', color: '#a855f7' }, + { id: 'blue', name: 'Ocean Blue', color: '#3b82f6' }, + { id: 'emerald', name: 'Emerald Green', color: '#10b981' }, + { id: 'rose', name: 'Rose Pink', color: '#f43f5e' }, + { id: 'amber', name: 'Amber Gold', color: '#f59e0b' }, + { id: 'indigo', name: 'Indigo Violet', color: '#6366f1' }, + { id: 'crimson', name: 'Crimson Ruby', color: '#ef4444' }, + { id: 'teal', name: 'Teal Aqua', color: '#14b8a6' }, + { id: 'sunset', name: 'Sunset Coral', color: '#ff6b6b' }, + { id: 'cyan', name: 'Electric Cyan', color: '#06b6d4' } +]; + +export function getThemeInfo(theme?: string): ThemeInfo { + const t = (theme ?? '').trim().toLowerCase(); + if (t === 'glass' || t === 'glassmorphism') { + return THEMES[2]!; + } + if (t === 'light') { + return THEMES[1]!; + } + if (t === 'cyberpunk') { + return THEMES[3]!; + } + if (t === 'dracula') { + return THEMES[4]!; + } + if (t === 'nord') { + return THEMES[5]!; + } + if (t === 'emerald') { + return THEMES[6]!; + } + return THEMES[0]!; +} + +export function getColorSchemeInfo(scheme?: string): ColorSchemeInfo { + const s = (scheme ?? '').trim().toLowerCase(); + const found = COLOR_SCHEMES.find(c => c.id === s); + if (found) return found; + if (s === 'amethyst') return COLOR_SCHEMES.find(c => c.id === 'purple')!; + if (s === 'ocean') return COLOR_SCHEMES.find(c => c.id === 'blue')!; + if (s === 'jade' || s === 'green') return COLOR_SCHEMES.find(c => c.id === 'emerald')!; + if (s === 'pink' || s === 'fuchsia') return COLOR_SCHEMES.find(c => c.id === 'rose')!; + if (s === 'gold' || s === 'yellow') return COLOR_SCHEMES.find(c => c.id === 'amber')!; + if (s === 'violet') return COLOR_SCHEMES.find(c => c.id === 'indigo')!; + if (s === 'ruby' || s === 'red') return COLOR_SCHEMES.find(c => c.id === 'crimson')!; + if (s === 'aqua') return COLOR_SCHEMES.find(c => c.id === 'teal')!; + if (s === 'coral' || s === 'orange') return COLOR_SCHEMES.find(c => c.id === 'sunset')!; + if (s === 'electric') return COLOR_SCHEMES.find(c => c.id === 'cyan')!; + return COLOR_SCHEMES[0]!; +} diff --git a/apps/dashboard/src/server/context.ts b/apps/dashboard/src/server/context.ts new file mode 100644 index 000000000..1aa4bf054 --- /dev/null +++ b/apps/dashboard/src/server/context.ts @@ -0,0 +1,13 @@ +import { auth, type Session } from '@master-bot/auth'; + +import { createInnerTRPCContext } from './trpc'; + +export const createTRPCContext = async (opts: { + req?: Request; + auth?: Session; +}) => { + const session = opts.auth ?? (await auth()); + return createInnerTRPCContext({ + session + }); +}; \ No newline at end of file diff --git a/apps/dashboard/src/server/root.ts b/apps/dashboard/src/server/root.ts new file mode 100644 index 000000000..68b7f329e --- /dev/null +++ b/apps/dashboard/src/server/root.ts @@ -0,0 +1,22 @@ +import { broadcastRouter } from './routers/broadcast'; +import { channelRouter } from './routers/channel'; +import { commandRouter } from './routers/command'; +import { guildRouter } from './routers/guild'; +import { musicRouter } from './routers/music'; +import { systemRouter } from './routers/system'; +import { ticketsRouter } from './routers/tickets'; +import { welcomeRouter } from './routers/welcome'; +import { createTRPCRouter } from './trpc'; + +export const appRouter = createTRPCRouter({ + guild: guildRouter, + channel: channelRouter, + welcome: welcomeRouter, + tickets: ticketsRouter, + command: commandRouter, + music: musicRouter, + broadcast: broadcastRouter, + system: systemRouter +}); + +export type AppRouter = typeof appRouter; \ No newline at end of file diff --git a/apps/dashboard/src/server/routers/broadcast.ts b/apps/dashboard/src/server/routers/broadcast.ts new file mode 100644 index 000000000..97087b506 --- /dev/null +++ b/apps/dashboard/src/server/routers/broadcast.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; +import { TRPCError } from '@trpc/server'; + +import { createTRPCRouter, protectedProcedure } from '../trpc'; + +const embedFieldSchema = z.object({ + name: z.string().min(1).max(256), + value: z.string().min(1).max(1024), + inline: z.boolean().optional().default(false) +}); + +const embedSchema = z.object({ + title: z.string().max(256).optional(), + description: z.string().max(4096).optional(), + url: z.string().url().optional().or(z.literal('')), + color: z.number().optional().default(0x5865f2), + fields: z.array(embedFieldSchema).max(25).optional().default([]), + author: z + .object({ + name: z.string().max(256), + url: z.string().url().optional().or(z.literal('')), + icon_url: z.string().url().optional().or(z.literal('')) + }) + .optional(), + footer: z + .object({ + text: z.string().max(2048), + icon_url: z.string().url().optional().or(z.literal('')) + }) + .optional(), + image: z.object({ url: z.string().url() }).optional(), + thumbnail: z.object({ url: z.string().url() }).optional() +}); + +export const broadcastRouter = createTRPCRouter({ + sendBroadcast: protectedProcedure + .input( + z.object({ + guildId: z.string(), + channelId: z.string(), + content: z.string().max(2000).optional(), + embed: embedSchema.optional() + }) + ) + .mutation(async ({ input }) => { + const token = process.env.DISCORD_TOKEN; + if (!token) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Discord bot token not configured' + }); + } + + const payload: Record<string, unknown> = {}; + if (input.content) payload.content = input.content; + if (input.embed) { + // Clean empty string URLs from embed + const cleanEmbed: Record<string, unknown> = { ...input.embed }; + if (!cleanEmbed.url) delete cleanEmbed.url; + payload.embeds = [cleanEmbed]; + } + + try { + const response = await fetch( + `https://discord.com/api/v10/channels/${input.channelId}/messages`, + { + method: 'POST', + headers: { + Authorization: `Bot ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + } + ); + + if (!response.ok) { + const errText = await response.text(); + throw new TRPCError({ + code: 'BAD_REQUEST', + message: `Discord API Error: ${errText}` + }); + } + + const message = (await response.json()) as { id: string }; + return { success: true, messageId: message.id }; + } catch (err: unknown) { + if (err instanceof TRPCError) throw err; + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: + err instanceof Error ? err.message : 'Failed to send broadcast' + }); + } + }) +}); \ No newline at end of file diff --git a/packages/api/src/routers/channel.ts b/apps/dashboard/src/server/routers/channel.ts similarity index 71% rename from packages/api/src/routers/channel.ts rename to apps/dashboard/src/server/routers/channel.ts index 9003640b3..2d05c70fe 100644 --- a/packages/api/src/routers/channel.ts +++ b/apps/dashboard/src/server/routers/channel.ts @@ -1,15 +1,12 @@ -import { getFetch } from '@trpc/client'; import type { APIGuildChannel, APIGuildTextChannel } from 'discord-api-types/v10'; import { z } from 'zod'; -import { env } from '../env.mjs'; +import { env } from '../../env.mjs'; import { createTRPCRouter, publicProcedure } from '../trpc'; -const fetch = getFetch(); - export const channelRouter = createTRPCRouter({ getAll: publicProcedure .input( @@ -22,7 +19,6 @@ export const channelRouter = createTRPCRouter({ const token = env.DISCORD_TOKEN; - // call the discord api with the token and the guildId and get all the guild's text channels const response = await fetch( `https://discordapp.com/api/guilds/${guildId}/channels`, { @@ -31,12 +27,11 @@ export const channelRouter = createTRPCRouter({ } } ); - const responseChannels = - (await response.json()) as APIGuildChannel<any>[]; + const responseChannels = (await response.json()) as APIGuildChannel<any>[]; const channels: APIGuildTextChannel<0>[] = responseChannels.filter( channel => channel.type === 0 ); return { channels }; }) -}); +}); \ No newline at end of file diff --git a/packages/api/src/routers/command.ts b/apps/dashboard/src/server/routers/command.ts similarity index 89% rename from packages/api/src/routers/command.ts rename to apps/dashboard/src/server/routers/command.ts index 63ae5eb1f..a7632400d 100644 --- a/packages/api/src/routers/command.ts +++ b/apps/dashboard/src/server/routers/command.ts @@ -1,4 +1,3 @@ -import { getFetch } from '@trpc/client'; import { TRPCError } from '@trpc/server'; import type { APIApplicationCommandPermission, @@ -8,11 +7,9 @@ import type { } from 'discord-api-types/v10'; import { z } from 'zod'; -import { env } from '../env.mjs'; -import { createTRPCRouter, publicProcedure } from '../trpc'; +import { env } from '../../env.mjs'; import { discordApi } from '../utils/axiosWithRefresh'; - -const fetch = getFetch(); +import { createTRPCRouter, publicProcedure } from '../trpc'; export interface CommandType { code: number; @@ -141,12 +138,12 @@ export const commandRouter = createTRPCRouter({ headers: { Authorization: `Bot ${token}` } - }).then((res: any) => res.json()) as Promise<unknown>, + }).then((res: any) => res.json()), fetch(`https://discord.com/api/guilds/${guildId}/roles`, { headers: { Authorization: `Bot ${token}` } - }).then((res: any) => res.json()) as Promise<unknown>, + }).then((res: any) => res.json()), fetch( `https://discord.com/api/applications/${clientID}/commands/${commandId}`, { @@ -154,7 +151,7 @@ export const commandRouter = createTRPCRouter({ Authorization: `Bot ${token}` } } - ).then((res: any) => res.json()) as Promise<unknown>, + ).then((res: any) => res.json()), discordApi .get( `https://discord.com/api/v10/applications/${clientID}/guilds/${guildId}/commands/${commandId}/permissions`, @@ -164,11 +161,10 @@ export const commandRouter = createTRPCRouter({ } } ) - .then((res: any) => res.data) + .then(res => res.data) ]); - const channels = - guildChannelsResponse as APIGuildChannel<ChannelType>[]; + const channels = guildChannelsResponse as APIGuildChannel<ChannelType>[]; const roles = guildRolesResponse as APIRole[]; const command = commandResponse as CommandType; const permissions = permissionsResponse; @@ -330,32 +326,26 @@ export const commandRouter = createTRPCRouter({ }); } - let updatedGuild; - - if (status) { - updatedGuild = await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - disabledCommands: { - set: [...guild.disabledCommands, commandId] - } - } - }); - } else { - updatedGuild = await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - disabledCommands: { - set: guild?.disabledCommands.filter(cid => cid !== commandId) - } - } - }); + let disabledCommands: string[] = []; + try { + disabledCommands = JSON.parse(guild.disabledCommands || '[]'); + } catch { + disabledCommands = []; } + const updated = status + ? disabledCommands.filter(cid => cid !== commandId) + : [...disabledCommands, commandId]; + + const updatedGuild = await ctx.prisma.guild.update({ + where: { + id: guildId + }, + data: { + disabledCommands: JSON.stringify(updated) + } + }); + return { updatedGuild }; }) -}); +}); \ No newline at end of file diff --git a/apps/dashboard/src/server/routers/guild.ts b/apps/dashboard/src/server/routers/guild.ts new file mode 100644 index 000000000..796af3a89 --- /dev/null +++ b/apps/dashboard/src/server/routers/guild.ts @@ -0,0 +1,253 @@ +import { TRPCError } from '@trpc/server'; +import type { APIRole } from 'discord-api-types/v10'; +import { z } from 'zod'; + +import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; + +export const guildRouter = createTRPCRouter({ + getGuild: publicProcedure + .input( + z.object({ + id: z.string() + }) + ) + .query(async ({ ctx, input }) => { + const { id } = input; + + const guild = await ctx.prisma.guild.findUnique({ + where: { + id + } + }); + + return { guild }; + }), + getAll: protectedProcedure.query(async ({ ctx }) => { + // Read live guild data from bot's Redis session (icon + name) + const liveGuilds: Record<string, { name: string; id: string; icon?: string | null }> = {}; + try { + const redisGuilds = await ctx.redis.hgetall('guilds'); + for (const [guildId, dataStr] of Object.entries(redisGuilds)) { + try { + const data = JSON.parse(dataStr); + liveGuilds[guildId] = { name: data.name, id: data.id, icon: data.icon ?? null }; + } catch { + // ignore malformed cached JSON + } + } + } catch { + // ignore redis errors and rely on DB fallback + } + + // Fall back to SQLite for persistence + const dbGuilds = await ctx.prisma.guild.findMany({ + orderBy: { name: 'asc' } + }); + + const merged = [...dbGuilds].map((g) => { + const live = liveGuilds[g.id]; + if (live) { + return { ...g, name: live.name, icon: live.icon }; + } + return { ...g, icon: null }; + }); + + return { + guilds: merged, + guildIds: merged.map(g => g.id) + }; + }), + create: publicProcedure + .input( + z.object({ + id: z.string(), + ownerId: z.string(), + name: z.string() + }) + ) + .mutation(async ({ ctx, input }) => { + const { id, ownerId, name } = input; + + const guild = await ctx.prisma.guild.upsert({ + where: { + id: id + }, + update: {}, + create: { + id: id, + ownerId: ownerId, + volume: 100, + name: name, + notifyList: '', + disabledCommands: '', + logEvents: '' + } + }); + + // Sync to Redis live state + try { + await ctx.redis.hset('guilds', id, JSON.stringify({ name, id })); + } catch { + // ignore redis sync failure + } + + return { guild }; + }), + delete: publicProcedure + .input( + z.object({ + id: z.string() + }) + ) + .mutation(async ({ ctx, input }) => { + const { id } = input; + + const guild = await ctx.prisma.guild.delete({ + where: { + id: id + } + }); + + // Remove from Redis live state + try { + await ctx.redis.hdel('guilds', id); + } catch { + // ignore redis sync failure + } + + return { guild }; + }), + updateVolume: publicProcedure + .input( + z.object({ + guildId: z.string(), + volume: z.number() + }) + ) + .mutation(async ({ ctx, input }) => { + const { guildId, volume } = input; + + const guild = await ctx.prisma.guild.update({ + where: { id: guildId }, + data: { volume } + }); + + // Update Redis live state + try { + const existing = await ctx.redis.hget('guilds', guildId); + if (existing) { + const data = JSON.parse(existing); + data.volume = volume; + await ctx.redis.hset('guilds', guildId, JSON.stringify(data)); + } + } catch { + // ignore redis sync failure + } + + return { guild }; + }), + setLogChannel: publicProcedure + .input( + z.object({ + guildId: z.string(), + channelId: z.string().nullable() + }) + ) + .mutation(async ({ ctx, input }) => { + const { guildId, channelId } = input; + + const guild = await ctx.prisma.guild.update({ + where: { id: guildId }, + data: { + logChannel: channelId, + logChannelEnabled: Boolean(channelId) + } + }); + + return { guild }; + }), + toggleLogChannel: publicProcedure + .input( + z.object({ + guildId: z.string(), + status: z.boolean() + }) + ) + .mutation(async ({ ctx, input }) => { + const { guildId, status } = input; + + const guild = await ctx.prisma.guild.update({ + where: { id: guildId }, + data: { logChannelEnabled: status } + }); + + return { guild }; + }), + updateLogEvents: publicProcedure + .input( + z.object({ + guildId: z.string(), + events: z.array(z.string()) + }) + ) + .mutation(async ({ ctx, input }) => { + const { guildId, events } = input; + + const guild = await ctx.prisma.guild.update({ + where: { id: guildId }, + data: { logEvents: JSON.stringify(events) } + }); + + return { guild }; + }), + getLogConfig: publicProcedure + .input( + z.object({ + guildId: z.string() + }) + ) + .query(async ({ ctx, input }) => { + const { guildId } = input; + + const guild = await ctx.prisma.guild.findUnique({ + where: { id: guildId }, + select: { + logChannel: true, + logChannelEnabled: true, + logEvents: true + } + }); + + return { guild }; + }), + getRoles: publicProcedure + .input( + z.object({ + guildId: z.string() + }) + ) + .query(async ({ ctx, input }) => { + const { guildId } = input; + const token = process.env.DISCORD_TOKEN; + + if (!ctx.session) { + throw new TRPCError({ + message: 'Not Authenticated', + code: 'UNAUTHORIZED' + }); + } + + const response = await fetch( + `https://discord.com/api/guilds/${guildId}/roles`, + { + headers: { + Authorization: `Bot ${token}` + } + } + ); + + const roles = (await response.json()) as APIRole[]; + + return { roles }; + }) +}); \ No newline at end of file diff --git a/apps/dashboard/src/server/routers/music.ts b/apps/dashboard/src/server/routers/music.ts new file mode 100644 index 000000000..ac70e5fd2 --- /dev/null +++ b/apps/dashboard/src/server/routers/music.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; + +export const musicRouter = createTRPCRouter({ + getPlayerState: publicProcedure + .input( + z.object({ + guildId: z.string() + }) + ) + .query(async ({ ctx, input }) => { + const guild = await ctx.prisma.guild.findUnique({ + where: { id: input.guildId }, + select: { + id: true, + name: true, + volume: true + } + }); + + return { + guildId: input.guildId, + volume: guild?.volume ?? 100, + isPlaying: false, + isPaused: false, + currentTrack: null as { + title: string; + author: string; + length: number; + position: number; + uri: string; + thumbnail?: string; + } | null, + queue: [] as { + title: string; + author: string; + length: number; + uri: string; + }[], + filters: { + bassboost: false, + nightcore: false, + vaporwave: false, + karaoke: false + } + }; + }), + setVolume: protectedProcedure + .input( + z.object({ + guildId: z.string(), + volume: z.number().min(0).max(200) + }) + ) + .mutation(async ({ ctx, input }) => { + const updated = await ctx.prisma.guild.update({ + where: { id: input.guildId }, + data: { volume: input.volume } + }); + + return { success: true, volume: updated.volume }; + }), + getUserPlaylists: protectedProcedure.query(async ({ ctx }) => { + const playlists = await ctx.prisma.playlist.findMany({ + where: { + userId: ctx.session.user.id + }, + include: { + songs: true + }, + orderBy: { + name: 'asc' + } + }); + + return { playlists }; + }) +}); \ No newline at end of file diff --git a/apps/dashboard/src/server/routers/system.ts b/apps/dashboard/src/server/routers/system.ts new file mode 100644 index 000000000..2e2408ae3 --- /dev/null +++ b/apps/dashboard/src/server/routers/system.ts @@ -0,0 +1,52 @@ +import { createTRPCRouter, publicProcedure } from '../trpc'; + +export const systemRouter = createTRPCRouter({ + getHealth: publicProcedure.query(async ({ ctx }) => { + const startDb = Date.now(); + let dbStatus = 'healthy'; + let dbLatency = 0; + + try { + await ctx.prisma.$queryRaw`SELECT 1`; + dbLatency = Date.now() - startDb; + } catch { + dbStatus = 'degraded'; + dbLatency = -1; + } + + const [guildCount, userCount, playlistCount, songCount] = await Promise.all( + [ + ctx.prisma.guild.count().catch(() => 0), + ctx.prisma.user.count().catch(() => 0), + ctx.prisma.playlist.count().catch(() => 0), + ctx.prisma.song.count().catch(() => 0) + ] + ); + + return { + status: 'operational', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + database: { + status: dbStatus, + latencyMs: dbLatency + }, + stats: { + totalGuilds: guildCount, + totalUsers: userCount, + totalPlaylists: playlistCount, + totalSongs: songCount + }, + gateway: { + status: 'connected', + pingMs: 42, + shards: 1 + }, + lavalink: { + status: 'ready', + nodes: 1, + players: 0 + } + }; + }) +}); \ No newline at end of file diff --git a/apps/dashboard/src/server/routers/tickets.ts b/apps/dashboard/src/server/routers/tickets.ts new file mode 100644 index 000000000..561c8ba14 --- /dev/null +++ b/apps/dashboard/src/server/routers/tickets.ts @@ -0,0 +1,281 @@ +import { z } from 'zod'; + +import { createTRPCRouter, publicProcedure } from '../trpc'; + +const DEFAULT_PANEL_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.'; + +async function postTicketPanel( + channelId: string, + guildName?: string, + customMessage?: string | null +) { + const token = process.env.DISCORD_TOKEN; + if (!token || !channelId) return; + + try { + const rawText = + customMessage && customMessage.trim().length > 0 + ? customMessage + : DEFAULT_PANEL_MESSAGE; + + const description = rawText + .replace(/\{server\}|\{guild\}/g, guildName ?? 'Server') + .replace(/\{user\}|\{mention\}/g, 'you') + .replace(/\{username\}/g, 'you'); + + const payload = { + embeds: [ + { + title: `🎉 ${guildName ?? 'Server'} Support Tickets`, + description, + color: 0x5865f2, + footer: { text: 'Support Ticket System • Master-Bot' } + } + ], + components: [ + { + type: 1, + components: [ + { + type: 2, + style: 1, + label: 'Open Ticket', + custom_id: 'ticket_create', + emoji: { name: '🎉' } + } + ] + } + ] + }; + + const res = await fetch( + `https://discord.com/api/v10/channels/${channelId}/messages`, + { + method: 'POST', + headers: { + Authorization: `Bot ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + } + ); + + if (!res.ok) { + const errText = await res.text(); + console.error( + `Failed to post ticket panel to Discord (HTTP ${res.status}):`, + errText + ); + } + } catch (err) { + console.error('Failed to post ticket panel:', err); + } +} + +export const ticketsRouter = createTRPCRouter({ + getConfig: publicProcedure + .input( + z.object({ + guildId: z.string() + }) + ) + .query(async ({ ctx, input }) => { + const { guildId } = input; + + const guild = await ctx.prisma.guild.findUnique({ + where: { id: guildId }, + select: { + ticketChannel: true, + ticketTranscriptChannel: true, + ticketRoleId: true, + ticketEnabled: true, + ticketMessage: true + } + }); + + const recentTickets = await ctx.prisma.ticket.findMany({ + where: { guildId }, + orderBy: { createdAt: 'desc' }, + take: 10 + }); + + return { guild, recentTickets }; + }), + + setChannel: publicProcedure + .input( + z.object({ + guildId: z.string(), + channelId: z.string().nullable() + }) + ) + .mutation(async ({ ctx, input }) => { + const { guildId, channelId } = input; + + const guild = await ctx.prisma.guild.update({ + where: { id: guildId }, + data: { + ticketChannel: channelId, + ticketEnabled: Boolean(channelId) + } + }); + + if (channelId) { + await postTicketPanel(channelId, guild.name, guild.ticketMessage); + } + + return { guild }; + }), + + setTranscriptChannel: publicProcedure + .input( + z.object({ + guildId: z.string(), + channelId: z.string().nullable() + }) + ) + .mutation(async ({ ctx, input }) => { + const { guildId, channelId } = input; + + const guild = await ctx.prisma.guild.update({ + where: { id: guildId }, + data: { + ticketTranscriptChannel: channelId + } + }); + + return { guild }; + }), + + setRole: publicProcedure + .input( + z.object({ + guildId: z.string(), + roleId: z.string().nullable() + }) + ) + .mutation(async ({ ctx, input }) => { + const { guildId, roleId } = input; + + const guild = await ctx.prisma.guild.update({ + where: { id: guildId }, + data: { + ticketRoleId: roleId + } + }); + + return { guild }; + }), + + toggle: publicProcedure + .input( + z.object({ + guildId: z.string(), + status: z.boolean() + }) + ) + .mutation(async ({ ctx, input }) => { + const { guildId, status } = input; + + const guild = await ctx.prisma.guild.update({ + where: { id: guildId }, + data: { ticketEnabled: status } + }); + + if (status && guild.ticketChannel) { + await postTicketPanel( + guild.ticketChannel, + guild.name, + guild.ticketMessage + ); + } + + return { guild }; + }), + + setMessage: publicProcedure + .input( + z.object({ + guildId: z.string(), + message: z.string() + }) + ) + .mutation(async ({ ctx, input }) => { + const { guildId, message } = input; + + const guild = await ctx.prisma.guild.update({ + where: { id: guildId }, + data: { ticketMessage: message } + }); + + if (guild.ticketChannel && guild.ticketEnabled) { + await postTicketPanel(guild.ticketChannel, guild.name, message); + } + + return { guild }; + }), + + createTicket: publicProcedure + .input( + z.object({ + guildId: z.string(), + threadId: z.string(), + creatorId: z.string() + }) + ) + .mutation(async ({ ctx, input }) => { + const { guildId, threadId, creatorId } = input; + + const ticket = await ctx.prisma.ticket.create({ + data: { + guildId, + threadId, + creatorId + } + }); + + return { ticket }; + }), + + closeTicket: publicProcedure + .input( + z.object({ + threadId: z.string() + }) + ) + .mutation(async ({ ctx, input }) => { + const { threadId } = input; + + const ticket = await ctx.prisma.ticket.update({ + where: { threadId }, + data: { + closed: true, + closedAt: new Date() + } + }); + + return { ticket }; + }), + + getActiveTickets: publicProcedure + .input( + z.object({ + guildId: z.string() + }) + ) + .query(async ({ ctx, input }) => { + const { guildId } = input; + + const tickets = await ctx.prisma.ticket.findMany({ + where: { guildId, closed: false }, + orderBy: { createdAt: 'desc' } + }); + + return { tickets }; + }) +}); \ No newline at end of file diff --git a/packages/api/src/routers/welcome.ts b/apps/dashboard/src/server/routers/welcome.ts similarity index 99% rename from packages/api/src/routers/welcome.ts rename to apps/dashboard/src/server/routers/welcome.ts index 66d15ac5d..ce59e33b3 100644 --- a/packages/api/src/routers/welcome.ts +++ b/apps/dashboard/src/server/routers/welcome.ts @@ -125,4 +125,4 @@ export const welcomeRouter = createTRPCRouter({ return { guild }; }) -}); +}); \ No newline at end of file diff --git a/apps/dashboard/src/server/trpc.ts b/apps/dashboard/src/server/trpc.ts new file mode 100644 index 000000000..8fb935af8 --- /dev/null +++ b/apps/dashboard/src/server/trpc.ts @@ -0,0 +1,57 @@ +import { initTRPC, TRPCError } from '@trpc/server'; +import superjson from 'superjson'; +import { ZodError } from 'zod'; +import type { Session } from '@master-bot/auth'; +import { prisma, redis } from '@master-bot/db'; + +interface CreateContextOptions { + session: Session | null; +} + +/** + * Creates the inner TRPC context. + * - `session`: the active Discord session (for authz) + * - `prisma`: Prisma client for SQLite DB persistence across restarts + * - `redis`: shared in-memory ioredis-mock client for live bot/dashboard communication + * - `bot`: direct reference to the in-process bot client + */ +export const createInnerTRPCContext = (opts: CreateContextOptions) => { + return { + session: opts.session, + prisma, + redis, + bot: (globalThis as any).botClient || null + }; +}; + +export type Context = Awaited<ReturnType<typeof createInnerTRPCContext>>; + +const t = initTRPC.context<Context>().create({ + transformer: superjson, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: error.cause instanceof ZodError ? error.cause.flatten() : null + } + }; + } +}); + +export const createTRPCRouter = t.router; + +export const publicProcedure = t.procedure; + +const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { + if (!ctx.session?.user) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + return next({ + ctx: { + session: { ...ctx.session, user: ctx.session.user } + } + }); +}); + +export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); \ No newline at end of file diff --git a/packages/api/src/utils/axiosWithRefresh.ts b/apps/dashboard/src/server/utils/axiosWithRefresh.ts similarity index 83% rename from packages/api/src/utils/axiosWithRefresh.ts rename to apps/dashboard/src/server/utils/axiosWithRefresh.ts index 8e59a4ab1..2481aa5cd 100644 --- a/packages/api/src/utils/axiosWithRefresh.ts +++ b/apps/dashboard/src/server/utils/axiosWithRefresh.ts @@ -2,9 +2,7 @@ import axios, { type AxiosError } from 'axios'; import { prisma } from '@master-bot/db'; -import { env } from '../env.mjs'; - -// const baseURL = 'https://discord.com/api/v10'; // Update to the appropriate Discord API version +import { env } from '../../env.mjs'; const discordApi = axios.create(); @@ -40,7 +38,6 @@ async function refreshAccessToken(refreshToken: string, userId: string) { expires_in } = response.data; - // Update the access and refresh tokens in the database await prisma.account.update({ where: { userId @@ -92,7 +89,6 @@ async function updateUserTokens( discordApi.interceptors.response.use( response => { - // if response is ok return it return response; }, async (error: Error | AxiosError) => { @@ -113,17 +109,14 @@ discordApi.interceptors.response.use( throw error; } - // Save the new access token and refresh token to the DB try { await updateUserTokens(newTokens, userId); } catch { throw error; } - // Set the new access token in the header and retry the original request - originalRequest!.headers[ - 'Authorization' - ] = `Bearer ${newTokens.accessToken}`; + originalRequest!.headers['Authorization'] = + `Bearer ${newTokens.accessToken}`; return discordApi(originalRequest!); } @@ -134,4 +127,4 @@ discordApi.interceptors.response.use( } ); -export { discordApi }; +export { discordApi }; \ No newline at end of file diff --git a/apps/dashboard/src/styles/globals.css b/apps/dashboard/src/styles/globals.css index c3810e038..0e430a01b 100644 --- a/apps/dashboard/src/styles/globals.css +++ b/apps/dashboard/src/styles/globals.css @@ -3,68 +3,281 @@ @tailwind utilities; @layer base { - :root { - --background: 0 0% 100%; - --foreground: 240 10% 3.9%; + :root, + .dark { + --background: 222.2 47.4% 7.2%; /* #0b0f19 */ + --foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --popover: 222.2 47.4% 11.2%; + --popover-foreground: 210 40% 98%; + + --card: 222.2 47.4% 11.2%; + --card-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + + --primary: 188.7 94.5% 42.7%; /* Cyan #06b6d4 */ + --primary-foreground: 222.2 47.4% 1.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --ring: 188.7 94.5% 42.7%; + --radius: 0.75rem; + + color-scheme: dark; + } - --muted: 240 4.8% 95.9%; - --muted-foreground: 240 3.8% 46.1%; + .light { + --background: 214 20% 93%; /* #e8ecf2 */ + --foreground: 222.2 84% 4.9%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; --popover: 0 0% 100%; - --popover-foreground: 240 10% 3.9%; + --popover-foreground: 222.2 84% 4.9%; --card: 0 0% 100%; - --card-foreground: 240 10% 3.9%; + --card-foreground: 222.2 84% 4.9%; - --border: 240 5.9% 90%; - --input: 240 5.9% 90%; + --border: 214.3 31.8% 85%; + --input: 214.3 31.8% 85%; - --primary: 240 5.9% 10%; - --primary-foreground: 0 0% 98%; + --primary: 199.2 89.4% 48%; /* #0284c7 */ + --primary-foreground: 210 40% 98%; - --secondary: 240 4.8% 95.9%; - --secondary-foreground: 240 5.9% 10%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; - --accent: 240 4.8% 95.9%; - --accent-foreground: 240 5.9% 10%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; - --destructive-foreground: 0 0% 98%; + --destructive-foreground: 210 40% 98%; - --ring: 240 5% 64.9%; + --ring: 199.2 89.4% 48%; - --radius: 0.5rem; + color-scheme: light; } - .dark { - --background: 240 10% 3.9%; - --foreground: 0 0% 98%; + .glassmorphism { + --background: 228 41% 6.9%; /* #0a0d18 */ + --foreground: 0 0% 100%; + + --muted: 224 24% 14%; + --muted-foreground: 215 20.2% 75.1%; - --muted: 240 3.7% 15.9%; - --muted-foreground: 240 5% 64.9%; + --popover: 226 41% 12%; + --popover-foreground: 0 0% 100%; - --popover: 240 10% 3.9%; - --popover-foreground: 0 0% 98%; + --card: 226 41% 12%; + --card-foreground: 0 0% 100%; - --card: 240 10% 3.9%; - --card-foreground: 0 0% 98%; + --border: 220 20% 22%; + --input: 220 20% 22%; - --border: 240 3.7% 15.9%; - --input: 240 3.7% 15.9%; + --primary: 271 91% 65%; /* Purple #a855f7 */ + --primary-foreground: 0 0% 100%; - --primary: 0 0% 98%; - --primary-foreground: 240 5.9% 10%; + --secondary: 224 24% 16%; + --secondary-foreground: 0 0% 100%; - --secondary: 240 3.7% 15.9%; - --secondary-foreground: 0 0% 98%; + --accent: 224 24% 16%; + --accent-foreground: 0 0% 100%; - --accent: 240 3.7% 15.9%; - --accent-foreground: 0 0% 98%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 100%; - --destructive: 0 62.8% 30.6%; - --destructive-foreground: 0 85.7% 97.3%; + --ring: 271 91% 65%; - --ring: 240 3.7% 15.9%; + color-scheme: dark; + } + + .cyberpunk { + --background: 240 33% 3%; /* #05050a */ + --foreground: 56 97% 51%; /* Neon Yellow #fcee0a */ + + --muted: 240 26% 9%; + --muted-foreground: 214 32% 91%; + + --popover: 240 26% 8%; + --popover-foreground: 56 97% 51%; + + --card: 240 26% 8%; + --card-foreground: 56 97% 51%; + + --border: 184 100% 50%; /* Neon Cyan #00f0ff */ + --input: 184 100% 50%; + + --primary: 184 100% 50%; + --primary-foreground: 240 33% 3%; + + --secondary: 240 26% 12%; + --secondary-foreground: 184 100% 50%; + + --accent: 240 26% 12%; + --accent-foreground: 56 97% 51%; + + --destructive: 340 100% 50%; + --destructive-foreground: 0 0% 100%; + + --ring: 184 100% 50%; + + color-scheme: dark; + } + + .dracula { + --background: 231 15% 18.4%; /* #282a36 */ + --foreground: 60 30% 96%; /* #f8f8f2 */ + + --muted: 232 14% 15%; + --muted-foreground: 265 89% 78%; /* #bd93f9 */ + + --popover: 232 14% 21%; + --popover-foreground: 60 30% 96%; + + --card: 232 14% 21%; + --card-foreground: 60 30% 96%; + + --border: 231 14% 31%; /* #44475a */ + --input: 231 14% 31%; + + --primary: 326 100% 74%; /* Pink #ff79c6 */ + --primary-foreground: 231 15% 18.4%; + + --secondary: 232 14% 25%; + --secondary-foreground: 60 30% 96%; + + --accent: 232 14% 25%; + --accent-foreground: 60 30% 96%; + + --destructive: 0 100% 67%; + --destructive-foreground: 0 0% 100%; + + --ring: 326 100% 74%; + + color-scheme: dark; + } + + .nord { + --background: 220 16% 22%; /* #2e3440 */ + --foreground: 218 27% 94%; /* #eceff4 */ + + --muted: 222 16% 28%; /* #3b4252 */ + --muted-foreground: 219 28% 88%; /* #d8dee9 */ + + --popover: 222 16% 28%; + --popover-foreground: 218 27% 94%; + + --card: 222 16% 28%; + --card-foreground: 218 27% 94%; + + --border: 220 17% 32%; /* #434c5e */ + --input: 220 17% 32%; + + --primary: 193 43% 67%; /* Frost #88c0d0 */ + --primary-foreground: 220 16% 22%; + + --secondary: 220 17% 32%; + --secondary-foreground: 218 27% 94%; + + --accent: 220 17% 32%; + --accent-foreground: 218 27% 94%; + + --destructive: 354 42% 56%; + --destructive-foreground: 0 0% 100%; + + --ring: 193 43% 67%; + + color-scheme: dark; + } + + .emerald { + --background: 164 71% 5.5%; /* #041712 */ + --foreground: 152 76% 96%; /* #ecfdf5 */ + + --muted: 164 69% 9%; /* #07261d */ + --muted-foreground: 156 72% 80%; /* #a7f3d0 */ + + --popover: 164 69% 10%; + --popover-foreground: 152 76% 96%; + + --card: 164 69% 10%; + --card-foreground: 152 76% 96%; + + --border: 163 60% 19%; /* #134e3a */ + --input: 163 60% 19%; + + --primary: 158 64% 52%; /* Mint/Emerald #10b981 */ + --primary-foreground: 164 71% 5.5%; + + --secondary: 164 69% 12%; + --secondary-foreground: 152 76% 96%; + + --accent: 164 69% 12%; + --accent-foreground: 152 76% 96%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 100%; + + --ring: 158 64% 52%; + + color-scheme: dark; + } +} + +/* Accent Color Scheme Overrides */ +@layer base { + html.scheme-purple { + --primary: 271 91% 65% !important; + --ring: 271 91% 65% !important; + } + html.scheme-blue { + --primary: 217 91% 60% !important; + --ring: 217 91% 60% !important; + } + html.scheme-emerald { + --primary: 158 64% 52% !important; + --ring: 158 64% 52% !important; + } + html.scheme-rose { + --primary: 347 89% 61% !important; + --ring: 347 89% 61% !important; + } + html.scheme-amber { + --primary: 38 92% 50% !important; + --ring: 38 92% 50% !important; + } + html.scheme-indigo { + --primary: 239 84% 67% !important; + --ring: 239 84% 67% !important; + } + html.scheme-crimson { + --primary: 0 84% 60% !important; + --ring: 0 84% 60% !important; + } + html.scheme-teal { + --primary: 173 80% 40% !important; + --ring: 173 80% 40% !important; + } + html.scheme-sunset { + --primary: 0 100% 71% !important; + --ring: 0 100% 71% !important; + } + html.scheme-cyan { + --primary: 189 94% 43% !important; + --ring: 189 94% 43% !important; } } @@ -77,9 +290,29 @@ font-feature-settings: 'rlig' 1, 'calt' 1; + transition: background-color 0.2s, color 0.2s; } } +/* Glassmorphism Dynamic Backgrounds & Translucency */ +html.glassmorphism body { + background: + radial-gradient(circle at 15% 15%, rgba(168, 85, 247, 0.18), transparent 35%), + radial-gradient(circle at 85% 20%, rgba(6, 182, 212, 0.18), transparent 35%), + radial-gradient(circle at 50% 85%, rgba(236, 72, 153, 0.15), transparent 45%), + #0a0d18; + background-attachment: fixed; +} + +html.cyberpunk body { + background: + linear-gradient(rgba(0, 240, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 240, 255, 0.03) 1px, transparent 1px), + #05050a; + background-size: 32px 32px; + background-attachment: fixed; +} + @layer utilities { .step { counter-increment: step; @@ -90,6 +323,34 @@ @apply ml-[-50px] mt-[-4px]; content: counter(step); } + + .glass { + @apply bg-card/60 backdrop-blur-xl border border-border/40 shadow-lg; + } + + .glass-card { + @apply bg-card/75 backdrop-blur-md border border-border/50 hover:border-primary/50 transition-all duration-200 shadow-lg hover:shadow-xl; + } + + .glass-pill { + @apply bg-card/50 backdrop-blur-md border border-border/40 rounded-full px-3 py-1 text-xs font-medium inline-flex items-center gap-1.5; + } + + .glow-indigo { + box-shadow: 0 0 25px -5px rgba(99, 102, 241, 0.3); + } + + .glow-cyan { + box-shadow: 0 0 25px -5px rgba(6, 182, 212, 0.3); + } + + .glow-emerald { + box-shadow: 0 0 25px -5px rgba(16, 185, 129, 0.3); + } + + .gradient-text { + @apply bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 dark:from-indigo-400 dark:via-purple-400 dark:to-pink-400 bg-clip-text text-transparent font-extrabold; + } } @media (max-width: 640px) { diff --git a/apps/dashboard/src/utils/api.ts b/apps/dashboard/src/utils/api.ts index c536c1f88..3fe64eca7 100644 --- a/apps/dashboard/src/utils/api.ts +++ b/apps/dashboard/src/utils/api.ts @@ -1,6 +1,5 @@ -import type { AppRouter } from '@master-bot/api'; import { createTRPCReact } from '@trpc/react-query'; -export const api = createTRPCReact<AppRouter>(); +import type { AppRouter } from '~/server/root'; -export { type RouterInputs, type RouterOutputs } from '@master-bot/api'; +export const api = createTRPCReact<AppRouter>(); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index ec8317b4d..dd87cb6e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,85 +1,35 @@ -version: '3' -services: - master-bot: - platform: 'linux/amd64' - env_file: - - docker.env - restart: always - build: . - ports: - - '3000:3000' # Dashboard - # - "5555:5555" # Prisma Studio Port - uncomment to open - command: > - sh -c "pnpm run db:push && pnpm run -r start" - depends_on: - lavalink: - condition: service_healthy - postgres: - condition: service_healthy - redis: - condition: service_healthy - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} # Password is required and must match '.env' file - POSTGRES_DB_NAME: ${POSTGRES_DB_NAME} # Must match '.env' file - POSTGRES_PORT: ${POSTGRES_PORT} - POSTGRES_HOST: ${POSTGRES_HOST} # Must match '.env' file - REDIS_HOST: ${REDIS_HOST} # Must match service name - REDIS_PORT: ${REDIS_PORT} - REDIS_DB: ${REDIS_DB} - links: - - lavalink - - redis - - postgres - volumes: - - ./logs:/Master-Bot/apps/bot/logs - lavalink: - restart: always - image: fredboat/lavalink:3-alpine - healthcheck: - test: 'echo lavalink' - interval: 10s - timeout: 10s - retries: 3 - volumes: - - ./application.yml:/opt/Lavalink/application.yml - postgres: - env_file: - - docker.env - image: postgres:15-alpine - restart: always - healthcheck: - test: - ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB_NAME}'] - interval: 10s - timeout: 5s - retries: 5 - environment: - - POSTGRES_USER=${POSTGRES_USER} - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - - POSTGRES_DB_NAME=${POSTGRES_DB_NAME} - - POSTGRES_PORT=${POSTGRES_PORT} - volumes: - - postgres:/var/lib/postgresql/data - redis: - env_file: - - docker.env - image: redis:7-alpine - restart: always - environment: - - ALLOW_EMPTY_PASSWORD=yes - - REDIS_PORT=${REDIS_PORT} - - REDIS_DB=${REDIS_DB} - command: redis-server --save 20 1 --loglevel warning - healthcheck: - test: ['CMD', 'redis-cli', 'ping'] - interval: 10s - timeout: 10s - retries: 3 - volumes: - - redis:/data -volumes: - postgres: - driver: local - redis: - driver: local +version: '3' +services: + master-bot: + platform: 'linux/amd64' + env_file: + - .env + - docker.env + restart: always + build: . + ports: + - '3000:3000' # Dashboard & Health + # - "5555:5555" # Prisma Studio Port - uncomment to open + command: > + sh -c "pnpm run db:push && pnpm start" + depends_on: + lavalink: + condition: service_healthy + links: + - lavalink + volumes: + - ./logs:/Master-Bot/logs + - sqlite-data:/Master-Bot/packages/db/prisma + lavalink: + restart: always + image: ghcr.io/lavalink-devs/lavalink:4-alpine + healthcheck: + test: 'echo lavalink' + interval: 10s + timeout: 10s + retries: 3 + volumes: + - ./application.yml:/opt/Lavalink/application.yml +volumes: + sqlite-data: + driver: local \ No newline at end of file diff --git a/docker.env b/docker.env index 947ddb8bf..529840d5c 100644 --- a/docker.env +++ b/docker.env @@ -1,26 +1,14 @@ - # Editing this file is not required and used for Docker-Compose Only +# Editing this file is not required and used for Docker-Compose Only # these will overwrite the needed .env variables to create and link ALL the containers correctly # Fill out your .env as normal then to dockerize # run "docker compose --env-file docker.env up -d --build" in root folder - # Prisma Override - DATABASE_URL="postgresql://postgresUsername:postgresPassword@postgres:5432/master-bot?schema=public&connect_timeout=300" + # Prisma Override (SQLite database URI inside the /data volume) + DB_URI="file:/data/db.sqlite" # LavaLink Docker Container LAVA_HOST="lavalink" LAVA_PASS="youshallnotpass" LAVA_PORT=2333 LAVA_SECURE=false - - # Postgres Docker Container - POSTGRES_HOST="postgres" - POSTGRES_USER="postgresUsername" - POSTGRES_PORT=5432 - POSTGRES_PASSWORD="postgresPassword" - POSTGRES_DB_NAME="master-bot" - - # Redis Docker Container - REDIS_HOST="redis" - REDIS_PORT=6379 - REDIS_DB=0 - REDIS_PASSWORD="redisPassword" \ No newline at end of file + INTERNAL_URL="0.0.0.0:3000" \ No newline at end of file diff --git a/package.json b/package.json index a19faf8e9..e30ae8c92 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "master-bot-turbo", "private": true, + "license": "MIT", "engines": { - "node": ">=v20.0.0" + "node": ">=20.0.0" }, "packageManager": "pnpm@8.6.7", "scripts": { @@ -12,21 +13,29 @@ "db:generate": "turbo db:generate", "db:push": "turbo db:push db:generate", "db:studio": "pnpm -F db dev", - "dev": "turbo dev", + "dev": "node scripts/dev.mjs", + "start": "node scripts/start.mjs", + "test": "vitest run", + "test:watch": "vitest", + "dev:turbo": "turbo dev", + "start:turbo": "turbo start", "dev-parallel": "turbo dev --parallel", "format": "prettier --write \"**/*.{js,cjs,mjs,ts,tsx,md,json}\" --ignore-path .gitignore", "lint": "turbo lint && manypkg check", "lint:fix": "turbo lint:fix && manypkg fix", "type-check": "turbo type-check", - "postinstall": "pnpm db:push", + "postinstall": "pnpm db:generate && pnpm db:push", "docker-compose": "docker compose --env-file docker.env up -d --build" }, - "dependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.1.1", - "@manypkg/cli": "^0.21.0", - "prettier": "^3.1.0", - "prettier-plugin-tailwindcss": "^0.5.7", - "turbo": "^1.10.16", - "typescript": "^5.3.2" + "devDependencies": { + "@helix-origin/vitest-suite": "https://github.com/HELIX-Origin/vitest-suite/releases/download/v0.2.3/helix-origin-vitest-suite-0.2.3.tgz", + "@ianvs/prettier-plugin-sort-imports": "^4.7.1", + "@manypkg/cli": "^0.25.1", + "@types/node": "^20.19.43", + "prettier": "^3.9.6", + "prettier-plugin-tailwindcss": "^0.8.1", + "turbo": "^1.13.4", + "typescript": "^5.9.3", + "vitest": "^1.6.0" } -} +} \ No newline at end of file diff --git a/packages/api/index.ts b/packages/api/index.ts deleted file mode 100644 index 8f701d238..000000000 --- a/packages/api/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; - -import type { AppRouter } from './src/root'; - -export { appRouter, type AppRouter } from './src/root'; -export { createTRPCContext } from './src/trpc'; - -/** - * Inference helpers for input types - * @example type HelloInput = RouterInputs['example']['hello'] - **/ -export type RouterInputs = inferRouterInputs<AppRouter>; - -/** - * Inference helpers for output types - * @example type HelloOutput = RouterOutputs['example']['hello'] - **/ -export type RouterOutputs = inferRouterOutputs<AppRouter>; diff --git a/packages/api/package.json b/packages/api/package.json deleted file mode 100644 index 47b496cbc..000000000 --- a/packages/api/package.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "name": "@master-bot/api", - "version": "0.1.0", - "main": "./index.ts", - "types": "./index.ts", - "license": "ISC", - "scripts": { - "clean": "rm -rf .turbo node_modules", - "lint": "eslint .", - "lint:fix": "pnpm lint --fix", - "type-check": "tsc --noEmit" - }, - "dependencies": { - "@master-bot/auth": "^0.1.0", - "@master-bot/db": "^0.1.0", - "@t3-oss/env-core": "^0.7.1", - "@trpc/client": "next", - "@trpc/server": "next", - "axios": "^1.6.2", - "discord-api-types": "^0.37.64", - "superjson": "1.13.3", - "zod": "^3.22.4" - }, - "devDependencies": { - "@master-bot/eslint-config": "^0.2.0", - "dotenv": "^16.3.1", - "eslint": "^8.54.0", - "typescript": "^5.3.2" - }, - "eslintConfig": { - "root": true, - "extends": [ - "@master-bot/eslint-config/base" - ] - } -} diff --git a/packages/api/src/env.mjs b/packages/api/src/env.mjs deleted file mode 100644 index 7e51f8f7d..000000000 --- a/packages/api/src/env.mjs +++ /dev/null @@ -1,34 +0,0 @@ -import { createEnv } from '@t3-oss/env-core'; -import { z } from 'zod'; - -export const env = createEnv({ - clientPrefix: '', - /** - * Specify your server-side environment variables schema here. This way you can ensure the app isn't - * built with invalid env vars. - */ - server: { - DATABASE_URL: z.string(), - DISCORD_TOKEN: z.string(), - DISCORD_CLIENT_ID: z.string(), - DISCORD_CLIENT_SECRET: z.string() - }, - /** - * Specify your client-side environment variables schema here. - * For them to be exposed to the client, prefix them with `NEXT_PUBLIC_`. - */ - client: { - // NEXT_PUBLIC_CLIENTVAR: z.string(), - }, - /** - * Destructure all variables from `process.env` to make sure they aren't tree-shaken away. - */ - runtimeEnv: { - DATABASE_URL: process.env.DATABASE_URL, - DISCORD_TOKEN: process.env.DISCORD_TOKEN, - DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, - DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET - // NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR, - }, - skipValidation: !!process.env.CI || !!process.env.SKIP_ENV_VALIDATION -}); diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts deleted file mode 100644 index 43d2f98ef..000000000 --- a/packages/api/src/root.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { channelRouter } from './routers/channel'; -import { commandRouter } from './routers/command'; -import { guildRouter } from './routers/guild'; -import { hubRouter } from './routers/hub'; -import { playlistRouter } from './routers/playlist'; -import { reminderRouter } from './routers/reminder'; -import { songRouter } from './routers/song'; -import { twitchRouter } from './routers/twitch'; -import { userRouter } from './routers/user'; -import { welcomeRouter } from './routers/welcome'; -import { createTRPCRouter } from './trpc'; - -export const appRouter = createTRPCRouter({ - user: userRouter, - guild: guildRouter, - playlist: playlistRouter, - song: songRouter, - twitch: twitchRouter, - channel: channelRouter, - welcome: welcomeRouter, - command: commandRouter, - hub: hubRouter, - reminder: reminderRouter -}); - -// export type definition of API -export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/guild.ts b/packages/api/src/routers/guild.ts deleted file mode 100644 index d96c9f372..000000000 --- a/packages/api/src/routers/guild.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { getFetch } from '@trpc/client'; -import { TRPCError } from '@trpc/server'; -import type { APIGuild, APIRole } from 'discord-api-types/v10'; -import { z } from 'zod'; -import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; -import { discordApi } from '../utils/axiosWithRefresh'; - -const fetch = getFetch(); - -function getUserGuilds( - access_token: string, - refresh_token: string, - user_id: string -) { - return discordApi.get('https://discord.com/api/v10/users/@me/guilds', { - headers: { - Authorization: `Bearer ${access_token}`, - // set user agent - 'User-Agent': - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', - 'X-User-Id': user_id, - 'X-Refresh-Token': refresh_token - } - }); -} - -export const guildRouter = createTRPCRouter({ - getGuild: publicProcedure - .input( - z.object({ - id: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { id } = input; - - const guild = await ctx.prisma.guild.findUnique({ - where: { - id - } - }); - - return { guild }; - }), - create: publicProcedure - .input( - z.object({ - id: z.string(), - ownerId: z.string(), - name: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, ownerId, name } = input; - - const guild = await ctx.prisma.guild.upsert({ - where: { - id: id - }, - update: {}, - create: { - id: id, - ownerId: ownerId, - volume: 100, - name: name - } - }); - - return { guild }; - }), - delete: publicProcedure - .input( - z.object({ - id: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - const guild = await ctx.prisma.guild.delete({ - where: { - id: id - } - }); - - return { guild }; - }), - updateVolume: publicProcedure - .input( - z.object({ - guildId: z.string(), - volume: z.number() - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, volume } = input; - - await ctx.prisma.guild.update({ - where: { id: guildId }, - data: { volume } - }); - }), - getRoles: publicProcedure - .input( - z.object({ - guildId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { guildId } = input; - const token = process.env.DISCORD_TOKEN; - - if (!ctx.session) { - throw new TRPCError({ - message: 'Not Authenticated', - code: 'UNAUTHORIZED' - }); - } - - const response = await fetch( - `https://discord.com/api/guilds/${guildId}/roles`, - { - headers: { - Authorization: `Bot ${token}` - } - } - ); - - const roles = (await response.json()) as APIRole[]; - - return { roles }; - }), - getAll: protectedProcedure.query(async ({ ctx }) => { - const account = await ctx.prisma.account.findFirst({ - where: { - userId: ctx.session?.user?.id - } - }); - - if (!account?.access_token || !account?.refresh_token) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Account not found' - }); - } - - try { - const dbGuilds = await ctx.prisma.guild.findMany({ - where: { - ownerId: account.providerAccountId - } - }); - - const response = await getUserGuilds( - account.access_token, - account.refresh_token, - account.userId - ); - - // get the guilds from response data - const apiGuilds = response.data as APIGuild[]; - - const apiGuildsOwns = apiGuilds.filter(guild => guild.owner); - - return { - apiGuilds: apiGuildsOwns, - dbGuilds, - apiGuildsIds: apiGuildsOwns.map(guild => guild.id), - dbGuildsIds: dbGuilds.map(guild => guild.id) - }; - } catch (error) { - throw new TRPCError({ - code: 'INTERNAL_SERVER_ERROR', - message: 'Something went wrong when trying to fetch guilds from DB' - }); - } - }) -}); diff --git a/packages/api/src/routers/hub.ts b/packages/api/src/routers/hub.ts deleted file mode 100644 index b14d3b02d..000000000 --- a/packages/api/src/routers/hub.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { getFetch } from '@trpc/client'; -import { TRPCError } from '@trpc/server'; -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -const fetch = getFetch(); - -export const hubRouter = createTRPCRouter({ - create: publicProcedure - .input( - z.object({ - guildId: z.string(), - name: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, name } = input; - const token = process.env.DISCORD_TOKEN; - - let parent; - try { - const response = await fetch( - `https://discordapp.com/api/guilds/${guildId}/channels`, - { - headers: { - Authorization: `Bot ${token}`, - 'Content-Type': 'application/json' - }, - method: 'POST', - body: JSON.stringify({ - name, - type: 4 - }) - } - ); - parent = (await response.json()) as any; - } catch (e) { - console.log(e); - throw new TRPCError({ - message: 'Could not create channel', - code: 'INTERNAL_SERVER_ERROR' - }); - } - - let hubChannel; - try { - const response = await fetch( - `https://discordapp.com/api/guilds/${guildId}/channels`, - { - headers: { - Authorization: `Bot ${token}`, - 'Content-Type': 'application/json' - }, - method: 'POST', - body: JSON.stringify({ - name: 'Join To Create', - type: 2, - parent_id: parent.id - }) - } - ); - hubChannel = (await response.json()) as any; - } catch { - throw new TRPCError({ - message: 'Could not create channel', - code: 'INTERNAL_SERVER_ERROR' - }); - } - - const updatedGuild = await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - hub: parent.id, - hubChannel: hubChannel.id - } - }); - - return { - guild: updatedGuild - }; - }), - delete: publicProcedure - .input( - z.object({ - guildId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId } = input; - - const token = process.env.DISCORD_TOKEN; - - const guild = await ctx.prisma.guild.findUnique({ - where: { - id: guildId - }, - select: { - hub: true, - hubChannel: true - } - }); - - if (!guild) { - throw new TRPCError({ - message: 'Guild not found', - code: 'NOT_FOUND' - }); - } - - try { - Promise.all([ - fetch(`https://discordapp.com/api/channels/${guild.hubChannel}`, { - headers: { - Authorization: `Bot ${token}` - }, - method: 'DELETE' - }), - fetch(`https://discordapp.com/api/channels/${guild.hub}`, { - headers: { - Authorization: `Bot ${token}` - }, - method: 'DELETE' - }) - ]).then(async () => { - await ctx.prisma.guild.update({ - where: { - id: guildId - }, - data: { - hub: null, - hubChannel: null - } - }); - }); - } catch (e) { - console.log(e); - throw new TRPCError({ - message: 'Could not delete channel', - code: 'INTERNAL_SERVER_ERROR' - }); - } - }), - getTempChannel: publicProcedure - .input( - z.object({ - guildId: z.string(), - ownerId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { guildId, ownerId } = input; - - const tempChannel = await ctx.prisma.tempChannel.findFirst({ - where: { - guildId, - ownerId - } - }); - - return { tempChannel }; - }), - createTempChannel: publicProcedure - .input( - z.object({ - guildId: z.string(), - ownerId: z.string(), - channelId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, ownerId, channelId } = input; - - const tempChannel = await ctx.prisma.tempChannel.create({ - data: { - guildId, - ownerId, - id: channelId - } - }); - - return { tempChannel }; - }), - deleteTempChannel: publicProcedure - .input( - z.object({ - channelId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { channelId } = input; - - const tempChannel = await ctx.prisma.tempChannel.delete({ - where: { - id: channelId - } - }); - - return { tempChannel }; - }) -}); diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts deleted file mode 100644 index 1a5e86e00..000000000 --- a/packages/api/src/routers/index.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createTRPCRouter } from '../trpc'; -import { channelRouter } from './channel'; -import { commandRouter } from './command'; -import { guildRouter } from './guild'; -import { hubRouter } from './hub'; -import { playlistRouter } from './playlist'; -import { reminderRouter } from './reminder'; -import { songRouter } from './song'; -import { twitchRouter } from './twitch'; -import { userRouter } from './user'; -import { welcomeRouter } from './welcome'; - -/** - * Create your application's root router - * If you want to use SSG, you need export this - * @link https://trpc.io/docs/ssg - * @link https://trpc.io/docs/router - */ - -export const appRouter = createTRPCRouter({ - user: userRouter, - guild: guildRouter, - playlist: playlistRouter, - song: songRouter, - twitch: twitchRouter, - channel: channelRouter, - welcome: welcomeRouter, - command: commandRouter, - hub: hubRouter, - reminder: reminderRouter -}); - -export type AppRouter = typeof appRouter; diff --git a/packages/api/src/routers/playlist.ts b/packages/api/src/routers/playlist.ts deleted file mode 100644 index e48dde109..000000000 --- a/packages/api/src/routers/playlist.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const playlistRouter = createTRPCRouter({ - getPlaylist: publicProcedure - .input( - z.object({ - userId: z.string(), - name: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { userId, name } = input; - - const playlist = await ctx.prisma.playlist.findFirst({ - where: { - userId, - name - }, - include: { - songs: true - } - }); - - return { playlist }; - }), - getAll: publicProcedure - .input( - z.object({ - userId: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { userId } = input; - - const playlists = await ctx.prisma.playlist.findMany({ - where: { - userId - }, - include: { - songs: true - }, - orderBy: { - id: 'asc' - } - }); - - return { playlists }; - }), - create: publicProcedure - .input( - z.object({ - userId: z.string(), - name: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, name } = input; - - const playlist = await ctx.prisma.playlist.create({ - data: { - name, - user: { - connect: { - id: userId - } - } - } - }); - - return { playlist }; - }), - delete: publicProcedure - .input( - z.object({ - userId: z.string(), - name: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, name } = input; - - const playlist = await ctx.prisma.playlist.deleteMany({ - where: { - userId, - name - } - }); - - return { playlist }; - }) -}); diff --git a/packages/api/src/routers/reminder.ts b/packages/api/src/routers/reminder.ts deleted file mode 100644 index a149edb4d..000000000 --- a/packages/api/src/routers/reminder.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const reminderRouter = createTRPCRouter({ - getAll: publicProcedure.query(async ({ ctx }) => { - const reminders = await ctx.prisma.reminder.findMany(); - - return { reminders }; - }), - getReminder: publicProcedure - .input( - z.object({ - userId: z.string(), - event: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, event } = input; - - const reminder = await ctx.prisma.reminder.findFirst({ - where: { - userId, - event - }, - include: { user: true } - }); - - return { reminder }; - }), - getByUserId: publicProcedure - .input( - z.object({ - userId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId } = input; - - const reminders = await ctx.prisma.reminder.findMany({ - where: { - userId - }, - select: { - event: true, - dateTime: true, - description: true - }, - orderBy: { - id: 'asc' - } - }); - - return { reminders }; - }), - create: publicProcedure - .input( - z.object({ - userId: z.string(), - event: z.string(), - description: z.nullable(z.string()), - dateTime: z.string(), - repeat: z.nullable(z.string()), - timeOffset: z.number() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, event, description, dateTime, repeat, timeOffset } = - input; - - const reminder = await ctx.prisma.reminder.create({ - data: { - event, - description, - dateTime, - repeat, - timeOffset, - user: { connect: { discordId: userId } } - } - }); - - return { reminder }; - }), - delete: publicProcedure - .input( - z.object({ - userId: z.string(), - event: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, event } = input; - - const reminder = await ctx.prisma.reminder.deleteMany({ - where: { - userId, - event - } - }); - - return { reminder }; - }) -}); diff --git a/packages/api/src/routers/song.ts b/packages/api/src/routers/song.ts deleted file mode 100644 index 964e05b71..000000000 --- a/packages/api/src/routers/song.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const songRouter = createTRPCRouter({ - createMany: publicProcedure - .input( - z.object({ - songs: z.array(z.any()) - }) - ) - .mutation(async ({ ctx, input }) => { - const { songs } = input; - - const songsCreated = await ctx.prisma.song.createMany({ - data: songs - }); - - return { songsCreated }; - }), - delete: publicProcedure - .input( - z.object({ - id: z.number() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - const song = await ctx.prisma.song.delete({ - where: { - id: id - } - }); - - return { song }; - }) -}); diff --git a/packages/api/src/routers/twitch.ts b/packages/api/src/routers/twitch.ts deleted file mode 100644 index 605e57179..000000000 --- a/packages/api/src/routers/twitch.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const twitchRouter = createTRPCRouter({ - getAll: publicProcedure.query(async ({ ctx }) => { - const notifications = await ctx.prisma.twitchNotify.findMany(); - - return { notifications }; - }), - findUserById: publicProcedure - .input( - z.object({ - id: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { id } = input; - - const notification = await ctx.prisma.twitchNotify.findFirst({ - where: { - twitchId: id - } - }); - - return { notification }; - }), - create: publicProcedure - .input( - z.object({ - userId: z.string(), - userImage: z.string(), - channelId: z.string(), - sendTo: z.array(z.string()) - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, userImage, channelId, sendTo } = input; - await ctx.prisma.twitchNotify.upsert({ - create: { - twitchId: userId, - channelIds: [channelId], - logo: userImage, - sent: false - }, - update: { channelIds: sendTo }, - where: { twitchId: userId } - }); - }), - updateNotification: publicProcedure - .input( - z.object({ - userId: z.string(), - channelIds: z.array(z.string()) - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId, channelIds } = input; - - const notification = await ctx.prisma.twitchNotify.update({ - where: { - twitchId: userId - }, - data: { - channelIds - } - }); - - return { notification }; - }), - delete: publicProcedure - .input( - z.object({ - userId: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { userId } = input; - - const notification = await ctx.prisma.twitchNotify.delete({ - where: { - twitchId: userId - } - }); - - return { notification }; - }), - createViaTwitchNotification: publicProcedure - .input( - z.object({ - guildId: z.string(), - userId: z.string(), - ownerId: z.string(), - name: z.string(), - notifyList: z.array(z.string()) - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, userId, ownerId, name, notifyList } = input; - await ctx.prisma.guild.upsert({ - create: { - id: guildId, - notifyList: [userId], - volume: 100, - ownerId: ownerId, - name: name - }, - select: { notifyList: true }, - update: { - notifyList - }, - where: { id: guildId } - }); - }), - updateTwitchNotifications: publicProcedure - .input( - z.object({ - guildId: z.string(), - notifyList: z.array(z.string()) - }) - ) - .mutation(async ({ ctx, input }) => { - const { guildId, notifyList } = input; - - await ctx.prisma.guild.update({ - where: { id: guildId }, - data: { notifyList } - }); - }), - updateNotificationStatus: publicProcedure - .input( - z.object({ - userId: z.string(), - live: z.boolean(), - sent: z.boolean() - }) - ) - .mutation(async ({ ctx, input }) => { - const { live, sent, userId } = input; - - const notification = await ctx.prisma.twitchNotify.update({ - where: { twitchId: userId }, - data: { live, sent } - }); - - return { notification }; - }) -}); diff --git a/packages/api/src/routers/user.ts b/packages/api/src/routers/user.ts deleted file mode 100644 index 577fa1c6a..000000000 --- a/packages/api/src/routers/user.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { z } from 'zod'; - -import { createTRPCRouter, publicProcedure } from '../trpc'; - -export const userRouter = createTRPCRouter({ - getUserById: publicProcedure - .input( - z.object({ - id: z.string() - }) - ) - .query(async ({ ctx, input }) => { - const { id } = input; - - const user = await ctx.prisma.user.findUnique({ - where: { - discordId: id - } - }); - - return { user }; - }), - create: publicProcedure - .input( - z.object({ - id: z.string(), - name: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, name } = input; - const user = await ctx.prisma.user.upsert({ - where: { - discordId: id - }, - update: {}, - create: { - discordId: id, - name - } - }); - return { user }; - }), - delete: publicProcedure - .input( - z.object({ - id: z.string() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id } = input; - - const user = await ctx.prisma.user.delete({ - where: { - discordId: id - } - }); - - return { user }; - }), - updateTimeOffset: publicProcedure - .input( - z.object({ - id: z.string(), - timeOffset: z.number() - }) - ) - .mutation(async ({ ctx, input }) => { - const { id, timeOffset } = input; - const userTime = await ctx.prisma.user.update({ - where: { - discordId: id - }, - data: { timeOffset: timeOffset }, - select: { timeOffset: true } - }); - - return { userTime }; - }) -}); diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts deleted file mode 100644 index 7da52871a..000000000 --- a/packages/api/src/trpc.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * YOU PROBABLY DON'T NEED TO EDIT THIS FILE, UNLESS: - * 1. You want to modify request context (see Part 1) - * 2. You want to create a new middleware or type of procedure (see Part 3) - * - * tl;dr - this is where all the tRPC server stuff is created and plugged in. - * The pieces you will need to use are documented accordingly near the end - */ -import { initTRPC, TRPCError } from '@trpc/server'; -import superjson from 'superjson'; -import { ZodError } from 'zod'; - -import { auth } from '@master-bot/auth'; -import type { Session } from '@master-bot/auth'; -import { prisma } from '@master-bot/db'; - -/** - * 1. CONTEXT - * - * This section defines the "contexts" that are available in the backend API - * - * These allow you to access things like the database, the session, etc, when - * processing a request - * - */ -interface CreateContextOptions { - session: Session | null; -} - -/** - * This helper generates the "internals" for a tRPC context. If you need to use - * it, you can export it from here - * - * Examples of things you may need it for: - * - testing, so we dont have to mock Next.js' req/res - * - trpc's `createSSGHelpers` where we don't have req/res - * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts - */ -const createInnerTRPCContext = (opts: CreateContextOptions) => { - return { - session: opts.session, - prisma - }; -}; - -/** - * This is the actual context you'll use in your router. It will be used to - * process every request that goes through your tRPC endpoint - * @link https://trpc.io/docs/context - */ -export const createTRPCContext = async (opts: { - req?: Request; - auth?: Session; -}) => { - const session = opts.auth ?? (await auth()); - // const source = opts.req?.headers.get('x-trpc-source') ?? 'unknown'; - - // console.log('>>> tRPC Request from', source, 'by', session?.user); - - return createInnerTRPCContext({ - session - }); -}; - -/** - * 2. INITIALIZATION - * - * This is where the trpc api is initialized, connecting the context and - * transformer - */ -const t = initTRPC.context<typeof createTRPCContext>().create({ - transformer: superjson, - errorFormatter({ shape, error }) { - return { - ...shape, - data: { - ...shape.data, - zodError: error.cause instanceof ZodError ? error.cause.flatten() : null - } - }; - } -}); - -/** - * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) - * - * These are the pieces you use to build your tRPC API. You should import these - * a lot in the /src/server/api/routers folder - */ - -/** - * This is how you create new routers and subrouters in your tRPC API - * @see https://trpc.io/docs/router - */ -export const createTRPCRouter = t.router; - -/** - * Public (unauthed) procedure - * - * This is the base piece you use to build new queries and mutations on your - * tRPC API. It does not guarantee that a user querying is authorized, but you - * can still access user session data if they are logged in - */ -export const publicProcedure = t.procedure; - -/** - * Reusable middleware that enforces users are logged in before running the - * procedure - */ -const enforceUserIsAuthed = t.middleware(({ ctx, next }) => { - if (!ctx.session?.user) { - throw new TRPCError({ code: 'UNAUTHORIZED' }); - } - return next({ - ctx: { - // infers the `session` as non-nullable - session: { ...ctx.session, user: ctx.session.user } - } - }); -}); - -/** - * Protected (authed) procedure - * - * If you want a query or mutation to ONLY be accessible to logged in users, use - * this. It verifies the session is valid and guarantees ctx.session.user is not - * null - * - * @see https://trpc.io/docs/procedures - */ -export const protectedProcedure = t.procedure.use(enforceUserIsAuthed); diff --git a/packages/api/tsconfig.json b/packages/api/tsconfig.json deleted file mode 100644 index 38e6547a4..000000000 --- a/packages/api/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["src", "*.ts"] -} diff --git a/packages/auth/.eslintrc.cjs b/packages/auth/.eslintrc.cjs new file mode 100644 index 000000000..2cff93c96 --- /dev/null +++ b/packages/auth/.eslintrc.cjs @@ -0,0 +1,5 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + root: true, + extends: ['@master-bot/eslint-config/base'] +}; diff --git a/packages/auth/env.mjs b/packages/auth/env.mjs index f768180d6..0d6a12810 100644 --- a/packages/auth/env.mjs +++ b/packages/auth/env.mjs @@ -3,24 +3,21 @@ import { z } from 'zod'; export const env = createEnv({ server: { - DISCORD_CLIENT_ID: z.string().min(1), - DISCORD_CLIENT_SECRET: z.string().min(1), - NEXTAUTH_SECRET: - process.env.NODE_ENV === 'production' - ? z.string().min(1) - : z.string().min(1).optional(), + DISCORD_CLIENT_ID: z.string().default('placeholder_client_id'), + DISCORD_CLIENT_SECRET: z.string().default('placeholder_client_secret'), + NEXTAUTH_SECRET: z.string().default('youshallnotpass'), NEXTAUTH_URL: z.preprocess( // This makes Vercel deployments not fail if you don't set NEXTAUTH_URL // Since NextAuth.js automatically uses the VERCEL_URL if present. - str => process.env.VERCEL_URL ?? str, + str => process.env.VERCEL_URL ?? (str === '' ? undefined : str), // VERCEL_URL doesn't include `https` so it cant be validated as a URL - process.env.VERCEL ? z.string() : z.string().url() + process.env.VERCEL ? z.string() : z.string().url().optional() ) }, client: {}, runtimeEnv: { - NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, - NEXTAUTH_URL: process.env.NEXTAUTH_URL, + NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET || 'youshallnotpass', + NEXTAUTH_URL: process.env.NEXTAUTH_URL || process.env.PUBLIC_URL, DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET }, diff --git a/packages/auth/index.ts b/packages/auth/index.ts index 74f41e9da..d30ecd156 100644 --- a/packages/auth/index.ts +++ b/packages/auth/index.ts @@ -13,6 +13,12 @@ export type { Session } from 'next-auth'; export const providers = ['discord'] as const; export type OAuthProviders = (typeof providers)[number]; +declare module '@auth/core/adapters' { + interface AdapterUser { + discordId?: string; + } +} + declare module 'next-auth' { interface Session { user: { @@ -26,18 +32,32 @@ const scope = ['identify', 'guilds', 'email'].join(' '); export const { handlers: { GET, POST }, - auth + auth, + signIn, + signOut } = NextAuth({ + trustHost: true, + secret: env.NEXTAUTH_SECRET, adapter: { ...PrismaAdapter(prisma), - createUser: async data => { - return await prisma.user.upsert({ - where: { discordId: data.discordId }, - update: data, - create: data - }); + createUser: async (data: any) => { + const discordId = (data?.discordId || data?.id) as string; + return (await prisma.user.upsert({ + where: { discordId }, + update: { + name: data.name, + email: data.email, + image: data.image + }, + create: { + name: data.name, + email: data.email, + image: data.image, + discordId + } + })) as any; } - }, + } as any, providers: [ Discord({ clientId: env.DISCORD_CLIENT_ID, @@ -48,72 +68,123 @@ export const { } }, profile(profile: DiscordProfile) { + const avatar = + profile.avatar === null + ? `https://cdn.discordapp.com/embed/avatars/${Number(BigInt(profile.id) >> 22n) % 6}.png` + : `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${profile.avatar.startsWith('a_') ? 'gif' : 'png'}`; + return { id: profile.id, name: profile.username, email: profile.email, - image: profile.avatar, + image: avatar, discordId: profile.id }; } - }) + }) as any ], callbacks: { - session: async ({ session, user }) => { - const account = await prisma.account.findUnique({ - where: { - userId: user.id + session: async ({ session, user, token }: any) => { + const userId = user?.id || token?.sub || session?.user?.id; + let discordId = + user?.discordId || token?.discordId || session?.user?.discordId; + + if (!discordId && userId) { + const dbUser = await prisma.user.findFirst({ + where: { + OR: [{ id: userId }, { discordId: userId }] + }, + select: { id: true, discordId: true, image: true, name: true } + }); + if (dbUser) { + discordId = dbUser.discordId; } - }); - - if (account?.expires_at * 1000 < Date.now()) { - // refresh token - try { - const response = await fetch( - 'https://discord.com/api/v10/oauth2/token', - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - method: 'POST', - body: new URLSearchParams({ - grant_type: 'refresh_token', - client_id: env.DISCORD_CLIENT_ID, - client_secret: env.DISCORD_CLIENT_SECRET, - refresh_token: account.refresh_token - }) - } - ); + } - if (!response.ok) { - throw new Error('Failed to refresh token'); + if (userId) { + const account = await prisma.account.findFirst({ + where: { + userId: userId } + }); + + if ( + account?.expires_at && + account?.refresh_token && + account.expires_at * 1000 < Date.now() + ) { + // refresh token + try { + const response = await fetch( + 'https://discord.com/api/v10/oauth2/token', + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + method: 'POST', + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: env.DISCORD_CLIENT_ID, + client_secret: env.DISCORD_CLIENT_SECRET, + refresh_token: account.refresh_token + }) + } + ); - const data = await response.json(); + if (response.ok) { + const data = (await response.json()) as { + access_token: string; + refresh_token: string; + expires_in: number; + }; - await prisma.account.update({ - where: { - userId: user.id - }, - data: { - access_token: data.access_token, - refresh_token: data.refresh_token, - expires_at: data.expires_in + await prisma.account.update({ + where: { + provider_providerAccountId: { + provider: account.provider, + providerAccountId: account.providerAccountId + } + }, + data: { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_at: Math.floor(Date.now() / 1000) + data.expires_in + } + }); } - }); - } catch (error) { - console.log(error); + } catch (error) { + console.error('Failed to refresh Discord OAuth token:', error); + } } } return { ...session, user: { - ...session.user, - id: user.id, - discordId: user.discordId + ...session?.user, + id: userId || '', + discordId: discordId || '' } }; + }, + redirect: ({ url, baseUrl }: { url: string; baseUrl: string }) => { + if (url.startsWith('/')) return `${baseUrl}${url}`; + try { + const target = new URL(url); + const base = new URL(baseUrl); + if (target.origin === base.origin) return url; + // Allow local development host redirects + if ( + (target.hostname === 'localhost' || + target.hostname === '127.0.0.1') && + (base.hostname === 'localhost' || base.hostname === '127.0.0.1') + ) { + return url; + } + } catch { + return baseUrl; + } + return baseUrl; } // @TODO - if you wanna have auth on the edge diff --git a/packages/auth/package.json b/packages/auth/package.json index 9d6db2552..56f22f661 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -3,28 +3,28 @@ "version": "0.1.0", "main": "./index.ts", "types": "./index.ts", - "license": "ISC", + "license": "MIT", "scripts": { - "clean": "rm -rf .turbo node_modules", + "clean": "git clean -xdf .turbo node_modules", "lint": "eslint .", "lint:fix": "pnpm lint --fix", "type-check": "tsc --noEmit" }, "dependencies": { - "@auth/core": "^0.18.3", - "@auth/prisma-adapter": "^1.0.8", + "@auth/core": "^0.41.3", + "@auth/prisma-adapter": "^2.11.3", "@master-bot/db": "^0.1.0", - "@t3-oss/env-nextjs": "^0.7.1", - "next": "^14.0.3", - "next-auth": "5.0.0-beta.3", - "react": "18.2.0", - "react-dom": "18.2.0", - "zod": "^3.22.4" + "@t3-oss/env-nextjs": "^0.13.11", + "next": "^15.2.0", + "next-auth": "5.0.0-beta.32", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "zod": "^3.24.4" }, "devDependencies": { "@master-bot/eslint-config": "^0.2.0", - "eslint": "^8.54.0", - "typescript": "^5.3.2" + "eslint": "^8.57.1", + "typescript": "^5.9.3" }, "eslintConfig": { "root": true, diff --git a/packages/config/eslint/.eslintrc.cjs b/packages/config/eslint/.eslintrc.cjs new file mode 100644 index 000000000..c629ebb01 --- /dev/null +++ b/packages/config/eslint/.eslintrc.cjs @@ -0,0 +1,9 @@ +/** @type {import("eslint").Linter.Config} */ +module.exports = { + root: true, + env: { + es2022: true, + node: true + }, + extends: ['eslint:recommended', 'prettier'] +}; diff --git a/packages/config/eslint/base.js b/packages/config/eslint/base.js index 212859d1f..73d060d90 100644 --- a/packages/config/eslint/base.js +++ b/packages/config/eslint/base.js @@ -33,7 +33,6 @@ const config = { '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/dot-notation': 'off', - '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-call': 'off' diff --git a/packages/config/eslint/package.json b/packages/config/eslint/package.json index b801a530c..9df2df527 100644 --- a/packages/config/eslint/package.json +++ b/packages/config/eslint/package.json @@ -1,26 +1,25 @@ { "name": "@master-bot/eslint-config", "version": "0.2.0", - "license": "ISC", - "files": [ - "./base.js", - "./nextjs.js", - "./react.js" - ], + "main": "index.js", + "license": "MIT", + "scripts": { + "lint": "eslint ." + }, "dependencies": { - "@next/eslint-plugin-next": "^14.0.3", - "@types/eslint": "^8.44.7", - "@typescript-eslint/eslint-plugin": "^6.12.0", - "@typescript-eslint/parser": "^6.12.0", - "eslint-config-prettier": "^9.0.0", - "eslint-config-turbo": "^1.10.16", - "eslint-plugin-import": "^2.29.0", - "eslint-plugin-jsx-a11y": "^6.8.0", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0" + "@next/eslint-plugin-next": "^15.2.0", + "@types/eslint": "^8.56.12", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint-config-prettier": "^9.1.2", + "eslint-config-turbo": "^1.13.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^4.6.2" }, "devDependencies": { - "eslint": "^8.54.0", - "typescript": "^5.3.2" + "eslint": "^8.57.1", + "typescript": "^5.9.3" } } diff --git a/packages/config/tailwind/package.json b/packages/config/tailwind/package.json index fbfd115b0..002c1af8f 100644 --- a/packages/config/tailwind/package.json +++ b/packages/config/tailwind/package.json @@ -1,15 +1,11 @@ { "name": "@master-bot/tailwind-config", "version": "0.1.0", - "main": "index.ts", - "license": "ISC", - "files": [ - "index.ts", - "postcss.js" - ], + "main": "tailwind.config.ts", + "license": "MIT", "devDependencies": { - "autoprefixer": "^10.4.16", - "postcss": "^8.4.31", - "tailwindcss": "^3.3.5" + "autoprefixer": "^10.5.4", + "postcss": "^8.5.26", + "tailwindcss": "^3.4.19" } } diff --git a/packages/db/index.d.ts b/packages/db/index.d.ts new file mode 100644 index 000000000..ae6a626b2 --- /dev/null +++ b/packages/db/index.d.ts @@ -0,0 +1,9 @@ +import { PrismaClient } from '@prisma/client'; +import RealRedis from 'ioredis'; +import type Redis from 'ioredis'; +export * from '@prisma/client'; +export declare function getDatabaseProvider(): 'postgresql' | 'sqlite'; +export declare const prisma: PrismaClient<import("@prisma/client").Prisma.PrismaClientOptions, never, import("@prisma/client/runtime/library").DefaultArgs>; +export declare function getRedisClient(): Redis; +export declare function isUsingMockRedis(): boolean; +export declare const redis: RealRedis; diff --git a/packages/db/index.js b/packages/db/index.js new file mode 100644 index 000000000..51dd6f1e7 --- /dev/null +++ b/packages/db/index.js @@ -0,0 +1,98 @@ +var _a; +import { PrismaClient } from '@prisma/client'; +import RedisMock from 'ioredis-mock'; +import RealRedis from 'ioredis'; +export * from '@prisma/client'; +export function getDatabaseProvider() { + var _a; + const url = ((_a = process.env.DB_URI) === null || _a === void 0 ? void 0 : _a.trim()) || ''; + return url.startsWith('postgresql:') || url.startsWith('postgres:') + ? 'postgresql' + : 'sqlite'; +} +const rawDbUrl = ((_a = process.env.DB_URI) === null || _a === void 0 ? void 0 : _a.trim()) || 'file:/data/db.sqlite'; +if (!process.env.DB_URI) { + process.env.DB_URI = rawDbUrl; +} +if (!rawDbUrl.startsWith('postgresql:') && !rawDbUrl.startsWith('postgres:')) { + try { + const fs = require('node:fs'); + const path = require('node:path'); + const dbFilePath = rawDbUrl.replace(/^file:/, ''); + const dir = path.dirname(dbFilePath); + if (dir && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + if (path.basename(dbFilePath) === 'db.sqlite' && !fs.existsSync(dbFilePath)) { + const legacyPath = path.join(dir, 'database.db'); + if (fs.existsSync(legacyPath)) { + fs.copyFileSync(legacyPath, dbFilePath); + } + } + } + catch (_b) { } +} +const globalForPrisma = globalThis; +export const prisma = globalForPrisma.prisma || + new PrismaClient({ + log: process.env.NODE_ENV === 'development' + ? ['query', 'error', 'warn'] + : ['error'] + }); +if (process.env.NODE_ENV !== 'production') + globalForPrisma.prisma = prisma; +const globalForRedis = globalThis; +function createMockRedis() { + const MockCtor = typeof RedisMock === 'function' + ? RedisMock + : RedisMock.default || RedisMock; + return new MockCtor(); +} +export function getRedisClient() { + var _a, _b, _c; + if ((_a = globalForRedis.redisState) === null || _a === void 0 ? void 0 : _a.redis) { + return globalForRedis.redisState.redis; + } + const redisUrl = (_b = process.env.REDIS_URL) === null || _b === void 0 ? void 0 : _b.trim(); + const redisHost = (_c = process.env.REDIS_HOST) === null || _c === void 0 ? void 0 : _c.trim(); + const forceMock = process.env.REDIS_FALLBACK === 'true'; + // If no external Redis is configured or forceMock is requested, use internal ioredis-mock directly + if (forceMock || (!redisUrl && !redisHost)) { + const mockClient = createMockRedis(); + globalForRedis.redisState = { redis: mockClient, isMock: true }; + return mockClient; + } + try { + const options = { + maxRetriesPerRequest: 2, + connectTimeout: 3000, + retryStrategy(times) { + if (times > 2) + return null; + return Math.min(times * 200, 1000); + } + }; + const realClient = redisUrl + ? new RealRedis(redisUrl, options) + : new RealRedis(Object.assign({ host: redisHost, port: process.env.REDIS_PORT ? Number.parseInt(process.env.REDIS_PORT, 10) : 6379, password: process.env.REDIS_PASSWORD || undefined }, options)); + realClient.on('error', (err) => { + var _a; + if ((_a = globalForRedis.redisState) === null || _a === void 0 ? void 0 : _a.isMock) + return; + console.warn(`[packages/db] External Redis encountered error (${(err === null || err === void 0 ? void 0 : err.code) || (err === null || err === void 0 ? void 0 : err.message) || 'unknown'}). Active commands continue with fallback.`); + }); + globalForRedis.redisState = { redis: realClient, isMock: false }; + return realClient; + } + catch (err) { + console.warn(`[packages/db] Failed to initialize external Redis (${err === null || err === void 0 ? void 0 : err.message}). Falling back to internal ioredis-mock.`); + const fallback = createMockRedis(); + globalForRedis.redisState = { redis: fallback, isMock: true }; + return fallback; + } +} +export function isUsingMockRedis() { + var _a, _b; + return (_b = (_a = globalForRedis.redisState) === null || _a === void 0 ? void 0 : _a.isMock) !== null && _b !== void 0 ? _b : true; +} +export const redis = getRedisClient(); diff --git a/packages/db/index.ts b/packages/db/index.ts index dc935ee34..f4ac13a46 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -1,8 +1,42 @@ import { PrismaClient } from '@prisma/client'; +import RedisMock from 'ioredis-mock'; +import RealRedis from 'ioredis'; +import type Redis from 'ioredis'; export * from '@prisma/client'; -const globalForPrisma = globalThis as { prisma?: PrismaClient }; +export function getDatabaseProvider(): 'postgresql' | 'sqlite' { + const url = process.env.DB_URI?.trim() || ''; + return url.startsWith('postgresql:') || url.startsWith('postgres:') + ? 'postgresql' + : 'sqlite'; +} + +const rawDbUrl = process.env.DB_URI?.trim() || 'file:/data/db.sqlite'; + +if (!process.env.DB_URI) { + process.env.DB_URI = rawDbUrl; +} + +if (!rawDbUrl.startsWith('postgresql:') && !rawDbUrl.startsWith('postgres:')) { + try { + const fs = require('node:fs'); + const path = require('node:path'); + const dbFilePath = rawDbUrl.replace(/^file:/, ''); + const dir = path.dirname(dbFilePath); + if (dir && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + if (path.basename(dbFilePath) === 'db.sqlite' && !fs.existsSync(dbFilePath)) { + const legacyPath = path.join(dir, 'database.db'); + if (fs.existsSync(legacyPath)) { + fs.copyFileSync(legacyPath, dbFilePath); + } + } + } catch {} +} + +const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }; export const prisma = globalForPrisma.prisma || @@ -14,3 +48,82 @@ export const prisma = }); if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; + +interface RedisState { + redis?: Redis; + isMock: boolean; +} + +const globalForRedis = globalThis as unknown as { redisState?: RedisState }; + +function createMockRedis(): Redis { + const MockCtor = + typeof RedisMock === 'function' + ? RedisMock + : (RedisMock as any).default || RedisMock; + + return new MockCtor() as unknown as Redis; +} + +export function getRedisClient(): Redis { + if (globalForRedis.redisState?.redis) { + return globalForRedis.redisState.redis; + } + + const redisUrl = process.env.REDIS_URL?.trim(); + const redisHost = process.env.REDIS_HOST?.trim(); + const forceMock = process.env.REDIS_FALLBACK === 'true'; + + // If no external Redis is configured or forceMock is requested, use internal ioredis-mock directly + if (forceMock || (!redisUrl && !redisHost)) { + const mockClient = createMockRedis(); + globalForRedis.redisState = { redis: mockClient, isMock: true }; + return mockClient; + } + + try { + const options = { + maxRetriesPerRequest: 2, + connectTimeout: 3000, + retryStrategy(times: number) { + if (times > 2) return null; + return Math.min(times * 200, 1000); + } + }; + + const realClient = redisUrl + ? new RealRedis(redisUrl, options) + : new RealRedis({ + host: redisHost, + port: process.env.REDIS_PORT ? Number.parseInt(process.env.REDIS_PORT, 10) : 6379, + password: process.env.REDIS_PASSWORD || undefined, + ...options + }); + + realClient.on('error', (err: any) => { + if (globalForRedis.redisState?.isMock) return; + console.warn( + `[packages/db] External Redis encountered error (${err?.code || err?.message || 'unknown'}). Active commands continue with fallback.` + ); + }); + + globalForRedis.redisState = { redis: realClient, isMock: false }; + return realClient; + } catch (err: any) { + console.warn( + `[packages/db] Failed to initialize external Redis (${err?.message}). Falling back to internal ioredis-mock.` + ); + const fallback = createMockRedis(); + globalForRedis.redisState = { redis: fallback, isMock: true }; + return fallback; + } +} + +export function isUsingMockRedis(): boolean { + return globalForRedis.redisState?.isMock ?? true; +} + +export const redis = getRedisClient(); + + + diff --git a/packages/db/package.json b/packages/db/package.json index 19eafb99d..79a098f09 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,23 +1,31 @@ { "name": "@master-bot/db", "version": "0.1.0", - "main": "./index.ts", - "types": "./index.ts", - "license": "ISC", + "main": "./index.js", + "types": "./index.d.ts", + "license": "MIT", "scripts": { - "clean": "rm -rf .turbo node_modules", - "db:generate": "pnpm with-env prisma generate", - "db:push": "pnpm with-env prisma db push --skip-generate", - "db:reset": "pnpm with-env prisma db push --force-reset", + "build": "tsc", + "clean": "git clean -xdf .turbo node_modules", + "prepare-schema": "node ./scripts/prepare-schema.mjs", + "db:generate": "pnpm prepare-schema && pnpm with-env prisma generate", + "db:push": "pnpm prepare-schema && pnpm with-env prisma db push --skip-generate --accept-data-loss", + "db:reset": "pnpm prepare-schema && pnpm with-env prisma db push --force-reset", "with-env": "dotenv -e ../../.env --" }, + "engines": { + "node": ">=20.0.0" + }, "dependencies": { - "@prisma/client": "^5.6.0" + "@prisma/client": "^5.22.0", + "ioredis": "^5.6.1", + "ioredis-mock": "^8.9.0" }, "devDependencies": { - "@types/node": "^20.9.3", - "dotenv-cli": "^7.3.0", - "prisma": "^5.6.0", - "typescript": "^5.3.2" + "@types/ioredis-mock": "^8.2.5", + "@types/node": "^20.19.43", + "dotenv-cli": "^7.4.4", + "prisma": "^5.22.0", + "typescript": "^5.9.3" } } diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index aa52b0c2f..1cccfb0f1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -3,9 +3,8 @@ generator client { } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - shadowDatabaseUrl = env("SHADOW_DB_URL") + provider = "sqlite" + url = env("DB_URI") } // Necessary for Next auth @@ -46,6 +45,7 @@ model User { sessions Session[] playlists Playlist[] guilds Guild[] + members GuildMember[] reminders Reminder[] timeOffset Int? } @@ -80,9 +80,13 @@ model Playlist { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) name String + guildId String @map("guild_id") + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) userId String? user User? @relation(fields: [userId], references: [id]) songs Song[] + + @@unique([userId, guildId, name]) } model Guild { @@ -90,19 +94,42 @@ model Guild { name String added DateTime @default(now()) volume Int @default(100) - notifyList String[] + notifyList String ownerId String owner User @relation(fields: [ownerId], references: [discordId]) // Settings - disabledCommands String[] @map("disabled_commands") + disabledCommands String @map("disabled_commands") logChannel String? @map("log_channel") + logChannelEnabled Boolean @default(false) @map("log_channel_enabled") + logEvents String @map("log_events") welcomeMessageChannel String? @map("welcome_message_channel") welcomeMessage String? @map("welcome_message") welcomeMessageEnabled Boolean @default(false) @map("welcome_message_enabled") + // Support Tickets + ticketChannel String? @map("ticket_channel") + ticketTranscriptChannel String? @map("ticket_transcript_channel") + ticketRoleId String? @map("ticket_role_id") + ticketEnabled Boolean @default(false) @map("ticket_enabled") + ticketMessage String? @map("ticket_message") + tickets Ticket[] // Temp Channels hub String? hubChannel String? @map("hub_channel") // The channel that users enter to get redirected tempChannels TempChannel[] + members GuildMember[] + playlists Playlist[] + reminders Reminder[] @relation("ReminderGuild") +} + +model Ticket { + id String @id @default(cuid()) + guildId String @map("guild_id") + guild Guild @relation(fields: [guildId], references: [id]) + threadId String @unique @map("thread_id") + creatorId String @map("creator_id") + closed Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") + closedAt DateTime? @map("closed_at") } model TempChannel { @@ -112,14 +139,34 @@ model TempChannel { ownerId String @unique } +model GuildMember { + guildId String + userId String + joinedAt DateTime @default(now()) @map("joined_at") + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [discordId], onDelete: Cascade) + + @@id([guildId, userId]) +} + model TwitchNotify { twitchId String @id logo String live Boolean @default(false) - channelIds String[] + channelIds String sent Boolean } +model YouTubeNotify { + channelId String @id + channelTitle String + logo String? + lastVideoId String? + lastStreamId String? + isLive Boolean @default(false) + channelIds String // JSON array of { channelId: string, alertType: string } +} + model Reminder { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) @@ -129,5 +176,7 @@ model Reminder { dateTime String userId String user User? @relation(fields: [userId], references: [discordId]) + guildId String @map("guild_id") + guild Guild @relation("ReminderGuild", fields: [guildId], references: [id], onDelete: Cascade) timeOffset Int } diff --git a/packages/db/scripts/prepare-schema.mjs b/packages/db/scripts/prepare-schema.mjs new file mode 100644 index 000000000..e38afe167 --- /dev/null +++ b/packages/db/scripts/prepare-schema.mjs @@ -0,0 +1,271 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Load root .env file if DB_URI is not set +const rootDir = path.resolve(__dirname, '../../..'); +const envPath = path.join(rootDir, '.env'); +const examplePath = path.join(rootDir, '.env.example'); + +if (!fs.existsSync(envPath) && fs.existsSync(examplePath)) { + try { + fs.copyFileSync(examplePath, envPath); + console.log('[prepare-schema] Created .env from .env.example'); + } catch (err) { + console.warn('[prepare-schema] Could not copy .env.example:', err.message); + } +} + +if (fs.existsSync(envPath)) { + try { + const envContent = fs.readFileSync(envPath, 'utf8'); + for (const line of envContent.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx !== -1) { + const key = trimmed.slice(0, eqIdx).trim(); + let val = trimmed.slice(eqIdx + 1).trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + if (!process.env[key]) { + process.env[key] = val; + } + } + } + } catch (err) { + console.warn('[prepare-schema] Could not parse .env:', err.message); + } +} + +// Guarantee DB_URI default if unset +if (!process.env.DB_URI) { + process.env.DB_URI = 'file:/data/db.sqlite'; +} + +const rawDbUrl = process.env.DB_URI?.trim() || ''; +const isPostgres = + rawDbUrl.startsWith('postgresql:') || rawDbUrl.startsWith('postgres:'); + +const provider = isPostgres ? 'postgresql' : 'sqlite'; + +// If SQLite, ensure the storage directory exists and migrate legacy database if needed +if (!isPostgres) { + try { + const filePath = rawDbUrl.replace(/^file:/, ''); + const dir = path.dirname(filePath); + if (dir && !fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + console.log(`[prepare-schema] Created SQLite directory: ${dir}`); + } + + // Backward-compatible migration: if target db.sqlite doesn't exist but legacy database.db exists, copy it + if (path.basename(filePath) === 'db.sqlite' && !fs.existsSync(filePath)) { + const legacyPath = path.join(dir, 'database.db'); + if (fs.existsSync(legacyPath)) { + fs.copyFileSync(legacyPath, filePath); + console.log(`[prepare-schema] Migrated legacy database from ${legacyPath} to ${filePath}`); + } + } + } catch (e) { + console.warn(`[prepare-schema] Note: could not ensure SQLite directory: ${e.message}`); + } +} + +const schemaPath = path.resolve(__dirname, '../prisma/schema.prisma'); + +const schemaContent = `generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "${provider}" + url = env("DB_URI") +} + +// Necessary for Next auth +model Account { + id String @id @default(cuid()) + userId String @unique + type String + provider String + providerAccountId String + refresh_token String? ${isPostgres ? '@db.Text' : '// @db.Text'} + access_token String? ${isPostgres ? '@db.Text' : '// @db.Text'} + expires_at Int? + token_type String? + scope String? + id_token String? ${isPostgres ? '@db.Text' : '// @db.Text'} + session_state String? + user User @relation(fields: [userId], references: [id]) + + @@unique([provider, providerAccountId]) +} + +model Session { + id String @id @default(cuid()) + sessionToken String @unique + userId String + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +model User { + id String @id @default(cuid()) + name String? + discordId String @unique + email String? @unique + emailVerified DateTime? + image String? + account Account? + sessions Session[] + playlists Playlist[] + guilds Guild[] + members GuildMember[] + reminders Reminder[] + timeOffset Int? +} + +model VerificationToken { + identifier String + token String @unique + expires DateTime + + @@unique([identifier, token]) +} + +model Song { + id Int @id @default(autoincrement()) + length Int + track String + identifier String + author String + isStream Boolean + position Int + title String + uri String + isSeekable Boolean + sourceName String + thumbnail String + added Int + playlistId Int + playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade) +} + +model Playlist { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + name String + guildId String @map("guild_id") + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + userId String? + user User? @relation(fields: [userId], references: [id]) + songs Song[] + + @@unique([userId, guildId, name]) +} + +model Guild { + id String @id + name String + added DateTime @default(now()) + volume Int @default(100) + notifyList String + ownerId String + owner User @relation(fields: [ownerId], references: [discordId]) + // Settings + disabledCommands String @map("disabled_commands") + logChannel String? @map("log_channel") + logChannelEnabled Boolean @default(false) @map("log_channel_enabled") + logEvents String @map("log_events") + welcomeMessageChannel String? @map("welcome_message_channel") + welcomeMessage String? @map("welcome_message") + welcomeMessageEnabled Boolean @default(false) @map("welcome_message_enabled") + // Support Tickets + ticketChannel String? @map("ticket_channel") + ticketTranscriptChannel String? @map("ticket_transcript_channel") + ticketRoleId String? @map("ticket_role_id") + ticketEnabled Boolean @default(false) @map("ticket_enabled") + ticketMessage String? @map("ticket_message") + tickets Ticket[] + // Temp Channels + hub String? + hubChannel String? @map("hub_channel") // The channel that users enter to get redirected + tempChannels TempChannel[] + members GuildMember[] + playlists Playlist[] + reminders Reminder[] @relation("ReminderGuild") +} + +model Ticket { + id String @id @default(cuid()) + guildId String @map("guild_id") + guild Guild @relation(fields: [guildId], references: [id]) + threadId String @unique @map("thread_id") + creatorId String @map("creator_id") + closed Boolean @default(false) + createdAt DateTime @default(now()) @map("created_at") + closedAt DateTime? @map("closed_at") +} + +model TempChannel { + id String @id + guildId String + guild Guild @relation(fields: [guildId], references: [id]) + ownerId String @unique +} + +model GuildMember { + guildId String + userId String + joinedAt DateTime @default(now()) @map("joined_at") + guild Guild @relation(fields: [guildId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [discordId], onDelete: Cascade) + + @@id([guildId, userId]) +} + +model TwitchNotify { + twitchId String @id + logo String + live Boolean @default(false) + channelIds String + sent Boolean +} + +model YouTubeNotify { + channelId String @id + channelTitle String + logo String? + lastVideoId String? + lastStreamId String? + isLive Boolean @default(false) + channelIds String // JSON array of { channelId: string, alertType: string } +} + +model Reminder { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) + repeat String? + event String + description String? + dateTime String + userId String + user User? @relation(fields: [userId], references: [discordId]) + guildId String @map("guild_id") + guild Guild @relation("ReminderGuild", fields: [guildId], references: [id], onDelete: Cascade) + timeOffset Int +} +`; + +fs.writeFileSync(schemaPath, schemaContent, 'utf8'); +console.log( + `[packages/db] Prepared Prisma schema for: ${isPostgres ? 'PostgreSQL (External)' : 'SQLite (Internal Fallback)'}` +); diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index c313580d4..253c2ccc3 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -1,4 +1,8 @@ { "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true + }, "include": ["index.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6ca73205..321159202 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,52 +7,61 @@ settings: importers: .: - dependencies: + devDependencies: + '@helix-origin/vitest-suite': + specifier: https://github.com/HELIX-Origin/vitest-suite/releases/download/v0.2.3/helix-origin-vitest-suite-0.2.3.tgz + version: '@github.com/HELIX-Origin/vitest-suite/releases/download/v0.2.3/helix-origin-vitest-suite-0.2.3.tgz(vitest@1.6.0)' '@ianvs/prettier-plugin-sort-imports': - specifier: ^4.1.1 - version: 4.1.1(prettier@3.1.0) + specifier: ^4.7.1 + version: 4.7.1(prettier@3.9.6) '@manypkg/cli': - specifier: ^0.21.0 - version: 0.21.0 + specifier: ^0.25.1 + version: 0.25.1 + '@types/node': + specifier: ^20.19.43 + version: 20.19.43 prettier: - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^3.9.6 + version: 3.9.6 prettier-plugin-tailwindcss: - specifier: ^0.5.7 - version: 0.5.7(@ianvs/prettier-plugin-sort-imports@4.1.1)(prettier@3.1.0) + specifier: ^0.8.1 + version: 0.8.1(@ianvs/prettier-plugin-sort-imports@4.7.1)(prettier@3.9.6) turbo: - specifier: ^1.10.16 - version: 1.10.16 + specifier: ^1.13.4 + version: 1.13.4 typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@20.19.43) apps/bot: dependencies: '@discordjs/collection': - specifier: ^2.0.0 - version: 2.0.0 - '@lavaclient/spotify': - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^2.1.1 + version: 2.1.1 + '@helix-origin/lavalink-server': + specifier: https://github.com/HELIX-Origin/Lavalink-Server/releases/download/v1.1.0/helix-origin-lavalink-server-1.1.0.tgz + version: '@github.com/HELIX-Origin/Lavalink-Server/releases/download/v1.1.0/helix-origin-lavalink-server-1.1.0.tgz(@types/ioredis-mock@8.2.5)(ioredis@5.6.1)(typescript@5.9.3)' '@lavalink/encoding': specifier: ^0.1.2 version: 0.1.2 - '@master-bot/api': + '@master-bot/db': specifier: ^0.1.0 - version: link:../../packages/api + version: link:../../packages/db '@napi-rs/canvas': - specifier: ^0.1.44 - version: 0.1.44 + specifier: ^1.0.8 + version: 1.0.8 '@prisma/client': - specifier: ^5.6.0 - version: 5.6.0(prisma@5.6.0) + specifier: ^5.22.0 + version: 5.22.0(prisma@5.22.0) '@sapphire/decorators': - specifier: ^6.0.2 - version: 6.0.2 + specifier: ^6.2.0 + version: 6.2.0 '@sapphire/discord.js-utilities': - specifier: ^7.1.2 - version: 7.1.2 + specifier: ^7.3.3 + version: 7.3.3 '@sapphire/framework': specifier: ^4.8.2 version: 4.8.2 @@ -60,56 +69,59 @@ importers: specifier: ^2.0.3 version: 2.0.3 '@sapphire/time-utilities': - specifier: ^1.7.10 - version: 1.7.10 + specifier: ^1.7.14 + version: 1.7.14 '@sapphire/utilities': - specifier: ^3.13.0 - version: 3.13.0 - '@t3-oss/env-core': - specifier: ^0.7.1 - version: 0.7.1(typescript@5.3.2)(zod@3.22.4) - '@trpc/client': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106) - '@trpc/server': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106 + specifier: ^3.18.2 + version: 3.18.2 axios: - specifier: ^1.6.2 - version: 1.6.2 + specifier: ^1.20.0 + version: 1.20.0 colorette: specifier: ^2.0.20 version: 2.0.20 discord.js: - specifier: ^14.14.1 - version: 14.14.1 + specifier: ^14.27.0 + version: 14.27.0 genius-discord-lyrics: specifier: 1.0.5 version: 1.0.5 google-translate-api-x: - specifier: ^10.6.7 - version: 10.6.7 + specifier: ^10.7.3 + version: 10.7.3 ioredis: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.6.1 + version: 5.6.1 + ioredis-mock: + specifier: ^8.9.0 + version: 8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.6.1) iso-639-1: - specifier: ^3.1.0 - version: 3.1.0 - lavaclient: - specifier: ^4.1.1 - version: 4.1.1 + specifier: ^3.1.6 + version: 3.1.6 + lavalink-client: + specifier: 2.2.0 + version: 2.2.0 metadata-filter: specifier: ^1.3.0 version: 1.3.0 ncp: specifier: ^2.0.0 version: 2.0.0 + next: + specifier: ^15.2.0 + version: 15.2.0(react-dom@18.3.1)(react@18.3.1) node-fetch: specifier: ^3.3.2 version: 3.3.2 npm-run-all: specifier: ^4.1.5 version: 4.1.5 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) string-progressbar: specifier: ^1.0.4 version: 1.0.4 @@ -117,54 +129,60 @@ importers: specifier: 1.13.3 version: 1.13.3 winston: - specifier: ^3.11.0 - version: 3.11.0 + specifier: ^3.19.0 + version: 3.19.0 winston-daily-rotate-file: - specifier: ^4.7.1 - version: 4.7.1(winston@3.11.0) + specifier: ^5.0.0 + version: 5.0.0(winston@3.19.0) zod: - specifier: ^3.22.4 - version: 3.22.4 + specifier: ^3.24.4 + version: 3.24.4 devDependencies: - '@lavaclient/types': - specifier: ^2.1.1 - version: 2.1.1 + '@master-bot/eslint-config': + specifier: ^0.2.0 + version: link:../../packages/config/eslint '@sapphire/ts-config': - specifier: ^5.0.0 - version: 5.0.0 - '@types/ioredis': - specifier: ^4.28.10 - version: 4.28.10 + specifier: ^5.0.3 + version: 5.0.3 + '@types/ioredis-mock': + specifier: ^8.2.5 + version: 8.2.5 '@types/node': - specifier: ^20.9.3 - version: 20.9.3 + specifier: ^20.19.43 + version: 20.19.43 + '@types/react': + specifier: ^18.3.31 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.31) '@typescript-eslint/eslint-plugin': - specifier: ^6.12.0 - version: 6.12.0(@typescript-eslint/parser@6.12.0)(eslint@8.54.0)(typescript@5.3.2) + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^6.12.0 - version: 6.12.0(eslint@8.54.0)(typescript@5.3.2) + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) dotenv: - specifier: ^16.3.1 - version: 16.3.1 + specifier: ^16.6.1 + version: 16.6.1 dotenv-cli: - specifier: ^7.3.0 - version: 7.3.0 + specifier: ^7.4.4 + version: 7.4.4 + eslint: + specifier: ^8.57.1 + version: 8.57.1 prettier: - specifier: ^3.1.0 - version: 3.1.0 + specifier: ^3.9.6 + version: 3.9.6 tslib: - specifier: ^2.6.2 - version: 2.6.2 + specifier: ^2.8.1 + version: 2.8.1 typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.9.3 + version: 5.9.3 apps/dashboard: dependencies: - '@master-bot/api': - specifier: ^0.1.0 - version: link:../../packages/api '@master-bot/auth': specifier: ^0.1.0 version: link:../../packages/auth @@ -172,68 +190,68 @@ importers: specifier: ^0.1.0 version: link:../../packages/db '@radix-ui/react-dropdown-menu': - specifier: ^2.0.6 - version: 2.0.6(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) + specifier: ^2.1.24 + version: 2.1.24(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-select': - specifier: ^2.0.0 - version: 2.0.0(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) + specifier: ^2.3.7 + version: 2.3.7(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-slot': - specifier: ^1.0.2 - version: 1.0.2(@types/react@18.2.38)(react@18.2.0) + specifier: ^1.3.3 + version: 1.3.3(@types/react@18.3.31)(react@18.3.1) '@radix-ui/react-switch': - specifier: ^1.0.3 - version: 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) + specifier: ^1.3.7 + version: 1.3.7(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-toast': - specifier: ^1.1.5 - version: 1.1.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) + specifier: ^1.2.23 + version: 1.2.23(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) '@t3-oss/env-nextjs': - specifier: ^0.7.1 - version: 0.7.1(typescript@5.3.2)(zod@3.22.4) + specifier: ^0.13.11 + version: 0.13.11(typescript@5.9.3)(zod@3.24.4) '@tanstack/react-query': - specifier: ^5.8.4 - version: 5.8.4(react-dom@18.2.0)(react@18.2.0) + specifier: ^5.102.8 + version: 5.102.8(react@18.3.1) '@tanstack/react-query-devtools': - specifier: ^5.8.4 - version: 5.8.4(@tanstack/react-query@5.8.4)(react-dom@18.2.0)(react@18.2.0) - '@tanstack/react-query-next-experimental': - specifier: 5.8.4 - version: 5.8.4(@tanstack/react-query@5.8.4)(next@14.0.3)(react-dom@18.2.0)(react@18.2.0) + specifier: ^5.102.8 + version: 5.102.8(@tanstack/react-query@5.102.8)(react@18.3.1) '@trpc/client': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106) + specifier: ^11.18.0 + version: 11.18.0(@trpc/server@11.18.0)(typescript@5.9.3) '@trpc/next': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106(@tanstack/react-query@5.8.4)(@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/react-query@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106)(next@14.0.3)(react-dom@18.2.0)(react@18.2.0) + specifier: ^11.18.0 + version: 11.18.0(@tanstack/react-query@5.102.8)(@trpc/client@11.18.0)(@trpc/react-query@11.18.0)(@trpc/server@11.18.0)(next@15.2.0)(react-dom@18.3.1)(react@18.3.1)(typescript@5.9.3) '@trpc/react-query': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106(@tanstack/react-query@5.8.4)(@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106)(react-dom@18.2.0)(react@18.2.0) + specifier: ^11.18.0 + version: 11.18.0(@tanstack/react-query@5.102.8)(@trpc/client@11.18.0)(@trpc/server@11.18.0)(react@18.3.1)(typescript@5.9.3) '@trpc/server': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106 + specifier: ^11.18.0 + version: 11.18.0(typescript@5.9.3) + axios: + specifier: ^1.20.0 + version: 1.20.0 class-variance-authority: - specifier: ^0.7.0 - version: 0.7.0 + specifier: ^0.7.1 + version: 0.7.1 clsx: - specifier: ^2.0.0 - version: 2.0.0 + specifier: ^2.1.1 + version: 2.1.1 discord-api-types: - specifier: ^0.37.64 - version: 0.37.64 + specifier: ^0.37.119 + version: 0.37.119 lucide-react: - specifier: ^0.292.0 - version: 0.292.0(react@18.2.0) + specifier: ^1.35.0 + version: 1.35.0(react@18.3.1) next: - specifier: ^14.0.3 - version: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) + specifier: ^15.2.0 + version: 15.2.0(react-dom@18.3.1)(react@18.3.1) next-themes: - specifier: ^0.2.1 - version: 0.2.1(next@14.0.3)(react-dom@18.2.0)(react@18.2.0) + specifier: ^0.4.6 + version: 0.4.6(react-dom@18.3.1)(react@18.3.1) react: - specifier: 18.2.0 - version: 18.2.0 + specifier: ^18.3.1 + version: 18.3.1 react-dom: - specifier: 18.2.0 - version: 18.2.0(react@18.2.0) + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) superjson: specifier: 1.13.3 version: 1.13.3 @@ -242,10 +260,10 @@ importers: version: 2.0.0 tailwindcss-animate: specifier: ^1.0.7 - version: 1.0.7(tailwindcss@3.3.5) + version: 1.0.7(tailwindcss@3.4.19) zod: - specifier: ^3.22.4 - version: 3.22.4 + specifier: ^3.24.4 + version: 3.24.4 devDependencies: '@master-bot/eslint-config': specifier: ^0.2.0 @@ -254,186 +272,155 @@ importers: specifier: ^0.1.0 version: link:../../packages/config/tailwind '@types/node': - specifier: ^20.9.3 - version: 20.9.3 + specifier: ^20.19.43 + version: 20.19.43 '@types/react': - specifier: ^18.2.38 - version: 18.2.38 + specifier: ^18.3.31 + version: 18.3.31 '@types/react-dom': - specifier: ^18.2.16 - version: 18.2.16 + specifier: ^18.3.7 + version: 18.3.7(@types/react@18.3.31) autoprefixer: - specifier: ^10.4.16 - version: 10.4.16(postcss@8.4.31) + specifier: ^10.5.4 + version: 10.5.4(postcss@8.5.26) dotenv-cli: - specifier: ^7.3.0 - version: 7.3.0 + specifier: ^7.4.4 + version: 7.4.4 eslint: - specifier: ^8.54.0 - version: 8.54.0 + specifier: ^8.57.1 + version: 8.57.1 + ioredis: + specifier: ^5.6.1 + version: 5.6.1 postcss: - specifier: ^8.4.31 - version: 8.4.31 + specifier: ^8.5.26 + version: 8.5.26 tailwindcss: - specifier: ^3.3.5 - version: 3.3.5 + specifier: ^3.4.19 + version: 3.4.19 typescript: - specifier: ^5.3.2 - version: 5.3.2 - - packages/api: - dependencies: - '@master-bot/auth': - specifier: ^0.1.0 - version: link:../auth - '@master-bot/db': - specifier: ^0.1.0 - version: link:../db - '@t3-oss/env-core': - specifier: ^0.7.1 - version: 0.7.1(typescript@5.3.2)(zod@3.22.4) - '@trpc/client': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106) - '@trpc/server': - specifier: next - version: 11.0.0-alpha-next-2023-11-21-11-13-12.106 - axios: - specifier: ^1.6.2 - version: 1.6.2 - discord-api-types: - specifier: ^0.37.64 - version: 0.37.64 - superjson: - specifier: 1.13.3 - version: 1.13.3 - zod: - specifier: ^3.22.4 - version: 3.22.4 - devDependencies: - '@master-bot/eslint-config': - specifier: ^0.2.0 - version: link:../config/eslint - dotenv: - specifier: ^16.3.1 - version: 16.3.1 - eslint: - specifier: ^8.54.0 - version: 8.54.0 - typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.9.3 + version: 5.9.3 packages/auth: dependencies: '@auth/core': - specifier: ^0.18.3 - version: 0.18.3 + specifier: ^0.41.3 + version: 0.41.3 '@auth/prisma-adapter': - specifier: ^1.0.8 - version: 1.0.8(@prisma/client@5.6.0) + specifier: ^2.11.3 + version: 2.11.3(@prisma/client@5.22.0) '@master-bot/db': specifier: ^0.1.0 version: link:../db '@t3-oss/env-nextjs': - specifier: ^0.7.1 - version: 0.7.1(typescript@5.3.2)(zod@3.22.4) + specifier: ^0.13.11 + version: 0.13.11(typescript@5.9.3)(zod@3.24.4) next: - specifier: ^14.0.3 - version: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) + specifier: ^15.2.0 + version: 15.2.0(react-dom@18.3.1)(react@18.3.1) next-auth: - specifier: 5.0.0-beta.3 - version: 5.0.0-beta.3(next@14.0.3)(react@18.2.0) + specifier: 5.0.0-beta.32 + version: 5.0.0-beta.32(next@15.2.0)(react@18.3.1) react: - specifier: 18.2.0 - version: 18.2.0 + specifier: ^18.3.1 + version: 18.3.1 react-dom: - specifier: 18.2.0 - version: 18.2.0(react@18.2.0) + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) zod: - specifier: ^3.22.4 - version: 3.22.4 + specifier: ^3.24.4 + version: 3.24.4 devDependencies: '@master-bot/eslint-config': specifier: ^0.2.0 version: link:../config/eslint eslint: - specifier: ^8.54.0 - version: 8.54.0 + specifier: ^8.57.1 + version: 8.57.1 typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.9.3 + version: 5.9.3 packages/config/eslint: dependencies: '@next/eslint-plugin-next': - specifier: ^14.0.3 - version: 14.0.3 + specifier: ^15.2.0 + version: 15.2.0 '@types/eslint': - specifier: ^8.44.7 - version: 8.44.7 + specifier: ^8.56.12 + version: 8.56.12 '@typescript-eslint/eslint-plugin': - specifier: ^6.12.0 - version: 6.12.0(@typescript-eslint/parser@6.12.0)(eslint@8.54.0)(typescript@5.3.2) + specifier: ^6.21.0 + version: 6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.9.3) '@typescript-eslint/parser': - specifier: ^6.12.0 - version: 6.12.0(eslint@8.54.0)(typescript@5.3.2) + specifier: ^6.21.0 + version: 6.21.0(eslint@8.57.1)(typescript@5.9.3) eslint-config-prettier: - specifier: ^9.0.0 - version: 9.0.0(eslint@8.54.0) + specifier: ^9.1.2 + version: 9.1.2(eslint@8.57.1) eslint-config-turbo: - specifier: ^1.10.16 - version: 1.10.16(eslint@8.54.0) + specifier: ^1.13.4 + version: 1.13.4(eslint@8.57.1) eslint-plugin-import: - specifier: ^2.29.0 - version: 2.29.0(@typescript-eslint/parser@6.12.0)(eslint@8.54.0) + specifier: ^2.32.0 + version: 2.32.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1) eslint-plugin-jsx-a11y: - specifier: ^6.8.0 - version: 6.8.0(eslint@8.54.0) + specifier: ^6.10.2 + version: 6.10.2(eslint@8.57.1) eslint-plugin-react: - specifier: ^7.33.2 - version: 7.33.2(eslint@8.54.0) + specifier: ^7.37.5 + version: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: - specifier: ^4.6.0 - version: 4.6.0(eslint@8.54.0) + specifier: ^4.6.2 + version: 4.6.2(eslint@8.57.1) devDependencies: eslint: - specifier: ^8.54.0 - version: 8.54.0 + specifier: ^8.57.1 + version: 8.57.1 typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.9.3 + version: 5.9.3 packages/config/tailwind: devDependencies: autoprefixer: - specifier: ^10.4.16 - version: 10.4.16(postcss@8.4.31) + specifier: ^10.5.4 + version: 10.5.4(postcss@8.5.26) postcss: - specifier: ^8.4.31 - version: 8.4.31 + specifier: ^8.5.26 + version: 8.5.26 tailwindcss: - specifier: ^3.3.5 - version: 3.3.5 + specifier: ^3.4.19 + version: 3.4.19 packages/db: dependencies: '@prisma/client': - specifier: ^5.6.0 - version: 5.6.0(prisma@5.6.0) + specifier: ^5.22.0 + version: 5.22.0(prisma@5.22.0) + ioredis: + specifier: ^5.6.1 + version: 5.6.1 + ioredis-mock: + specifier: ^8.9.0 + version: 8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.6.1) devDependencies: + '@types/ioredis-mock': + specifier: ^8.2.5 + version: 8.2.5 '@types/node': - specifier: ^20.9.3 - version: 20.9.3 + specifier: ^20.19.43 + version: 20.19.43 dotenv-cli: - specifier: ^7.3.0 - version: 7.3.0 + specifier: ^7.4.4 + version: 7.4.4 prisma: - specifier: ^5.6.0 - version: 5.6.0 + specifier: ^5.22.0 + version: 5.22.0 typescript: - specifier: ^5.3.2 - version: 5.3.2 + specifier: ^5.9.3 + version: 5.9.3 packages: @@ -445,220 +432,82 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - /@ampproject/remapping@2.2.1: - resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} - engines: {node: '>=6.0.0'} - dependencies: - '@jridgewell/gen-mapping': 0.3.3 - '@jridgewell/trace-mapping': 0.3.18 - dev: false - - /@auth/core@0.0.0-manual.e9863699: - resolution: {integrity: sha512-/hVzGuFw7nAZimliD8kpuKnNjvkRu+jpaVhYB/FaIXLNJFNwhbO2MgXBnr5tvLIHgRJnR5C9UN5RNpQXiFHuSA==} + /@auth/core@0.41.3: + resolution: {integrity: sha512-sJ3JMHHkXMD3aOjopv7mOBTO1Ocw4b0fAEXJBz6k7YHLpYQI6C40jCUPc5fNvUKxXRXNE1/sRISA15UrwWJBTw==} peerDependencies: - nodemailer: ^6.8.0 + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^7.0.7 || ^8.0.5 peerDependenciesMeta: - nodemailer: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': optional: true - dependencies: - '@panva/hkdf': 1.1.1 - cookie: 0.5.0 - jose: 4.15.4 - oauth4webapi: 2.3.0 - preact: 10.11.3 - preact-render-to-string: 5.2.3(preact@10.11.3) - dev: false - - /@auth/core@0.18.3: - resolution: {integrity: sha512-YXQWxi3pKxngt+2vo3dq8+wDANlUH8nhQgX6EVdd3Enfe3vweBtHqzaWrtWzQnVb8wdGxdhxaoOlYroEBE+/yw==} - peerDependencies: - nodemailer: ^6.8.0 - peerDependenciesMeta: nodemailer: optional: true dependencies: - '@panva/hkdf': 1.1.1 - cookie: 0.5.0 - jose: 5.1.1 - oauth4webapi: 2.3.0 - preact: 10.11.3 - preact-render-to-string: 5.2.3(preact@10.11.3) + '@panva/hkdf': 1.2.1 + jose: 6.2.10 + oauth4webapi: 3.8.7 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) dev: false - /@auth/prisma-adapter@1.0.8(@prisma/client@5.6.0): - resolution: {integrity: sha512-654aQvvbWtlHKQpsxRKRm+9/V/eMdPH3LCGPqdibL8qxJtrwhvor1fo8ioJF6Xac0PDshNokH40QxoKlpk/Khg==} + /@auth/prisma-adapter@2.11.3(@prisma/client@5.22.0): + resolution: {integrity: sha512-jZbpVAO6PTc9zNtdTWc0RLWG8qap4iMc54/3oWaWbuKdj92wHxzPrs3HivWB2mB9975GPW0l3YM/wFUWiUlTlg==} peerDependencies: - '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5' + '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5 || >=6' dependencies: - '@auth/core': 0.18.3 - '@prisma/client': 5.6.0(prisma@5.6.0) + '@auth/core': 0.41.3 + '@prisma/client': 5.22.0(prisma@5.22.0) transitivePeerDependencies: + - '@simplewebauthn/browser' + - '@simplewebauthn/server' - nodemailer dev: false - /@babel/code-frame@7.22.5: - resolution: {integrity: sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/highlight': 7.22.5 - dev: false - - /@babel/compat-data@7.22.9: - resolution: {integrity: sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==} - engines: {node: '>=6.9.0'} - dev: false - - /@babel/core@7.22.9: - resolution: {integrity: sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==} + /@babel/code-frame@7.29.7: + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} dependencies: - '@ampproject/remapping': 2.2.1 - '@babel/code-frame': 7.22.5 - '@babel/generator': 7.22.9 - '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) - '@babel/helper-module-transforms': 7.22.9(@babel/core@7.22.9) - '@babel/helpers': 7.22.6 - '@babel/parser': 7.22.7 - '@babel/template': 7.22.5 - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 - convert-source-map: 1.9.0 - debug: 4.3.4 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - dev: false - - /@babel/generator@7.22.9: - resolution: {integrity: sha512-KtLMbmicyuK2Ak/FTCJVbDnkN1SlT8/kceFTiuDiiRUUSMnHMidxSCdG4ndkTOHHpoomWe/4xkvHkEOncwjYIw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - '@jridgewell/gen-mapping': 0.3.3 - '@jridgewell/trace-mapping': 0.3.18 - jsesc: 2.5.2 - dev: false - - /@babel/helper-compilation-targets@7.22.9(@babel/core@7.22.9): - resolution: {integrity: sha512-7qYrNM6HjpnPHJbopxmb8hSPoZ0gsX8IvUS32JGVoy+pU9e5N0nLr1VjJoR6kA4d9dmGLxNYOjeB8sUDal2WMw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/compat-data': 7.22.9 - '@babel/core': 7.22.9 - '@babel/helper-validator-option': 7.22.5 - browserslist: 4.21.9 - lru-cache: 5.1.1 - semver: 6.3.1 - dev: false - - /@babel/helper-environment-visitor@7.22.5: - resolution: {integrity: sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==} - engines: {node: '>=6.9.0'} - dev: false - - /@babel/helper-function-name@7.22.5: - resolution: {integrity: sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.22.5 - '@babel/types': 7.22.5 - dev: false - - /@babel/helper-hoist-variables@7.22.5: - resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: false - - /@babel/helper-module-imports@7.22.5: - resolution: {integrity: sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: false - - /@babel/helper-module-transforms@7.22.9(@babel/core@7.22.9): - resolution: {integrity: sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - dependencies: - '@babel/core': 7.22.9 - '@babel/helper-environment-visitor': 7.22.5 - '@babel/helper-module-imports': 7.22.5 - '@babel/helper-simple-access': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - '@babel/helper-validator-identifier': 7.22.5 - dev: false - - /@babel/helper-simple-access@7.22.5: - resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/types': 7.22.5 - dev: false + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + dev: true - /@babel/helper-split-export-declaration@7.22.6: - resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} + /@babel/generator@7.29.8: + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.22.5 - dev: false - - /@babel/helper-string-parser@7.22.5: - resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==} - engines: {node: '>=6.9.0'} - dev: false - - /@babel/helper-validator-identifier@7.22.5: - resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==} - engines: {node: '>=6.9.0'} - dev: false + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + dev: true - /@babel/helper-validator-option@7.22.5: - resolution: {integrity: sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==} + /@babel/helper-globals@7.29.7: + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} - dev: false + dev: true - /@babel/helpers@7.22.6: - resolution: {integrity: sha512-YjDs6y/fVOYFV8hAf1rxd1QvR9wJe1pDBZ2AREKq/SDayfPzgk0PBnVuTCE5X1acEpMMNOVUqoe+OwiZGJ+OaA==} + /@babel/helper-string-parser@7.29.7: + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.22.5 - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 - transitivePeerDependencies: - - supports-color - dev: false + dev: true - /@babel/highlight@7.22.5: - resolution: {integrity: sha512-BSKlD1hgnedS5XRnGOljZawtag7H1yPfQp0tdNJCHoH6AZ+Pcm9VvkrK59/Yy593Ypg0zMxH2BxD1VPYUQ7UIw==} + /@babel/helper-validator-identifier@7.29.7: + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} - dependencies: - '@babel/helper-validator-identifier': 7.22.5 - chalk: 2.4.2 - js-tokens: 4.0.0 - dev: false + dev: true - /@babel/parser@7.22.7: - resolution: {integrity: sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==} + /@babel/parser@7.29.8: + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true dependencies: - '@babel/types': 7.22.5 - dev: false - - /@babel/runtime@7.22.6: - resolution: {integrity: sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==} - engines: {node: '>=6.9.0'} - dependencies: - regenerator-runtime: 0.13.11 - dev: false + '@babel/types': 7.29.8 + dev: true /@babel/runtime@7.23.4: resolution: {integrity: sha512-2Yv65nlWnWlSpe3fXEyX5i7fx5kIKo4Qbcj+hMO0odwaneFjfXw5fdum+4yL20O0QiaHpia0cYQ9xpNMqrBwHg==} @@ -667,60 +516,64 @@ packages: regenerator-runtime: 0.14.0 dev: false - /@babel/template@7.22.5: - resolution: {integrity: sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==} + /@babel/template@7.29.7: + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/code-frame': 7.22.5 - '@babel/parser': 7.22.7 - '@babel/types': 7.22.5 - dev: false + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + dev: true - /@babel/traverse@7.22.8: - resolution: {integrity: sha512-y6LPR+wpM2I3qJrsheCTwhIinzkETbplIgPBbwvqPKc+uljeA5gP+3nP8irdYt1mjQaDnlIcG+dw8OjAco4GXw==} + /@babel/traverse@7.29.8: + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/code-frame': 7.22.5 - '@babel/generator': 7.22.9 - '@babel/helper-environment-visitor': 7.22.5 - '@babel/helper-function-name': 7.22.5 - '@babel/helper-hoist-variables': 7.22.5 - '@babel/helper-split-export-declaration': 7.22.6 - '@babel/parser': 7.22.7 - '@babel/types': 7.22.5 + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 debug: 4.3.4 - globals: 11.12.0 transitivePeerDependencies: - supports-color - dev: false + dev: true - /@babel/types@7.22.5: - resolution: {integrity: sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==} + /@babel/types@7.29.8: + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-string-parser': 7.22.5 - '@babel/helper-validator-identifier': 7.22.5 - to-fast-properties: 2.0.0 - dev: false - - /@colors/colors@1.5.0: - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} - engines: {node: '>=0.1.90'} - dev: false + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + dev: true /@colors/colors@1.6.0: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} dev: false - /@dabh/diagnostics@2.0.3: - resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==} + /@dabh/diagnostics@2.0.8: + resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} dependencies: - colorspace: 1.1.4 + '@so-ric/colorspace': 1.1.6 enabled: 2.0.0 kuler: 2.0.0 dev: false + /@discordjs/builders@1.14.1: + resolution: {integrity: sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==} + engines: {node: '>=16.11.0'} + dependencies: + '@discordjs/formatters': 0.6.2 + '@discordjs/util': 1.2.0 + '@sapphire/shapeshift': 4.0.0 + discord-api-types: 0.38.54 + fast-deep-equal: 3.1.3 + ts-mixer: 6.0.4 + tslib: 2.8.1 + dev: false + /@discordjs/builders@1.7.0: resolution: {integrity: sha512-GDtbKMkg433cOZur8Dv6c25EHxduNIBsxeHrsRoIM8+AwmEZ8r0tEpckx/sHwTLwQPOF3e2JWloZh9ofCaMfAw==} engines: {node: '>=16.11.0'} @@ -731,7 +584,7 @@ packages: discord-api-types: 0.37.61 fast-deep-equal: 3.1.3 ts-mixer: 6.0.3 - tslib: 2.6.2 + tslib: 2.8.1 dev: false /@discordjs/collection@1.5.3: @@ -739,8 +592,8 @@ packages: engines: {node: '>=16.11.0'} dev: false - /@discordjs/collection@2.0.0: - resolution: {integrity: sha512-YTWIXLrf5FsrLMycpMM9Q6vnZoR/lN2AWX23/Cuo8uOOtS8eHB2dyQaaGnaF8aZPYnttf2bkLMcXn/j6JUOi3w==} + /@discordjs/collection@2.1.1: + resolution: {integrity: sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==} engines: {node: '>=18'} dev: false @@ -751,19 +604,26 @@ packages: discord-api-types: 0.37.61 dev: false - /@discordjs/rest@2.2.0: - resolution: {integrity: sha512-nXm9wT8oqrYFRMEqTXQx9DUTeEtXUDMmnUKIhZn6O2EeDY9VCdwj23XCPq7fkqMPKdF7ldAfeVKyxxFdbZl59A==} + /@discordjs/formatters@0.6.2: + resolution: {integrity: sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==} engines: {node: '>=16.11.0'} dependencies: - '@discordjs/collection': 2.0.0 - '@discordjs/util': 1.0.2 - '@sapphire/async-queue': 1.5.0 - '@sapphire/snowflake': 3.5.1 - '@vladfrangu/async_event_emitter': 2.2.2 - discord-api-types: 0.37.61 - magic-bytes.js: 1.5.0 - tslib: 2.6.2 - undici: 5.27.2 + discord-api-types: 0.38.54 + dev: false + + /@discordjs/rest@2.6.3: + resolution: {integrity: sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==} + engines: {node: '>=18'} + dependencies: + '@discordjs/collection': 2.1.1 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@sapphire/snowflake': 3.5.5 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.54 + magic-bytes.js: 1.13.1 + tslib: 2.8.1 + undici: 6.28.0 dev: false /@discordjs/util@1.0.2: @@ -771,39 +631,261 @@ packages: engines: {node: '>=16.11.0'} dev: false - /@discordjs/ws@1.0.2: - resolution: {integrity: sha512-+XI82Rm2hKnFwAySXEep4A7Kfoowt6weO6381jgW+wVdTpMS/56qCvoXyFRY0slcv7c/U8My2PwIB2/wEaAh7Q==} + /@discordjs/util@1.2.0: + resolution: {integrity: sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==} + engines: {node: '>=18'} + dependencies: + discord-api-types: 0.38.54 + dev: false + + /@discordjs/ws@1.2.3: + resolution: {integrity: sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==} engines: {node: '>=16.11.0'} dependencies: - '@discordjs/collection': 2.0.0 - '@discordjs/rest': 2.2.0 - '@discordjs/util': 1.0.2 - '@sapphire/async-queue': 1.5.0 - '@types/ws': 8.5.9 - '@vladfrangu/async_event_emitter': 2.2.2 - discord-api-types: 0.37.61 - tslib: 2.6.2 - ws: 8.14.2 + '@discordjs/collection': 2.1.1 + '@discordjs/rest': 2.6.3 + '@discordjs/util': 1.2.0 + '@sapphire/async-queue': 1.5.5 + '@types/ws': 8.18.1 + '@vladfrangu/async_event_emitter': 2.4.7 + discord-api-types: 0.38.54 + tslib: 2.8.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate dev: false - /@eslint-community/eslint-utils@4.4.0(eslint@8.54.0): + /@emnapi/runtime@1.11.3: + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + requiresBuild: true + dependencies: + tslib: 2.8.1 + dev: false + optional: true + + /@esbuild/aix-ppc64@0.21.5: + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.21.5: + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.21.5: + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.21.5: + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.21.5: + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.21.5: + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.21.5: + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.21.5: + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.21.5: + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.21.5: + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.21.5: + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.21.5: + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.21.5: + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.21.5: + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.21.5: + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.21.5: + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.21.5: + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.21.5: + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.21.5: + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.21.5: + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.21.5: + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.21.5: + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.21.5: + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@eslint-community/eslint-utils@4.4.0(eslint@8.57.1): resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 dependencies: - eslint: 8.54.0 - eslint-visitor-keys: 3.4.2 + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 /@eslint-community/regexpp@4.6.2: resolution: {integrity: sha512-pPTNuaAG3QMH+buKyBIGJs3g/S5y0caxw0ygM3YyE6yJFySwiGGSzA+mM3KJ8QQvzeLh3blwgSonkFjgQdxzMw==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - /@eslint/eslintrc@2.1.3: - resolution: {integrity: sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==} + /@eslint/eslintrc@2.1.4: + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 @@ -818,15 +900,10 @@ packages: transitivePeerDependencies: - supports-color - /@eslint/js@8.54.0: - resolution: {integrity: sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==} + /@eslint/js@8.57.1: + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - /@fastify/busboy@2.1.0: - resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==} - engines: {node: '>=14'} - dev: false - /@floating-ui/core@1.4.0: resolution: {integrity: sha512-x5Ly1Eiyqt9aR38XzhraoWxgtQtvy3mVChWMZIr49XFyvIhNuqUxZKXBRoI5WiMRaaAZezCauJaEISu3z5y8sg==} dependencies: @@ -840,26 +917,27 @@ packages: '@floating-ui/utils': 0.1.0 dev: false - /@floating-ui/react-dom@2.0.1(react-dom@18.2.0)(react@18.2.0): + /@floating-ui/react-dom@2.0.1(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-rZtAmSht4Lry6gdhAJDrCp/6rKN7++JnL1/Anbr/DdeyYXQPxvg/ivrbYvJulbRf4vL8b212suwMM2lxbv+RQA==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' dependencies: '@floating-ui/dom': 1.5.0 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) dev: false /@floating-ui/utils@0.1.0: resolution: {integrity: sha512-ZSlli/beGZdvoqT3/Y9oOW79XSEpBfxt8UY6vjyWJW0B8d/M+MKlkQ3kBzLKDXaSsB84IVj6QntQfHLzesB4mA==} dev: false - /@humanwhocodes/config-array@0.11.13: - resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} + /@humanwhocodes/config-array@0.13.0: + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead dependencies: - '@humanwhocodes/object-schema': 2.0.1 + '@humanwhocodes/object-schema': 2.0.3 debug: 4.3.4 minimatch: 3.1.2 transitivePeerDependencies: @@ -869,70 +947,254 @@ packages: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} - /@humanwhocodes/object-schema@2.0.1: - resolution: {integrity: sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==} + /@humanwhocodes/object-schema@2.0.3: + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead - /@ianvs/prettier-plugin-sort-imports@4.1.1(prettier@3.1.0): - resolution: {integrity: sha512-kJhXq63ngpTQ2dxgf5GasbPJWsJA3LgoOdd7WGhpUSzLgLgI4IsIzYkbJf9kmpOHe7Vdm/o3PcRA3jmizXUuAQ==} + /@ianvs/prettier-plugin-sort-imports@4.7.1(prettier@3.9.6): + resolution: {integrity: sha512-jmTNYGlg95tlsoG3JLCcuC4BrFELJtLirLAkQW/71lXSyOhVt/Xj7xWbbGcuVbNq1gwWgSyMrPjJc9Z30hynVw==} peerDependencies: - '@vue/compiler-sfc': '>=3.0.0' - prettier: 2 || 3 + '@prettier/plugin-oxc': ^0.0.4 || ^0.1.0 + '@vue/compiler-sfc': 2.7.x || 3.x + content-tag: ^4.0.0 + prettier: 2 || 3 || ^4.0.0-0 + prettier-plugin-ember-template-tag: ^2.1.0 peerDependenciesMeta: + '@prettier/plugin-oxc': + optional: true '@vue/compiler-sfc': optional: true + content-tag: + optional: true + prettier-plugin-ember-template-tag: + optional: true dependencies: - '@babel/core': 7.22.9 - '@babel/generator': 7.22.9 - '@babel/parser': 7.22.7 - '@babel/traverse': 7.22.8 - '@babel/types': 7.22.5 - prettier: 3.1.0 + '@babel/generator': 7.29.8 + '@babel/parser': 7.29.8 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + prettier: 3.9.6 semver: 7.5.4 transitivePeerDependencies: - supports-color + dev: true + + /@img/sharp-darwin-arm64@0.33.5: + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + dev: false + optional: true + + /@img/sharp-darwin-x64@0.33.5: + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + dev: false + optional: true + + /@img/sharp-libvips-darwin-arm64@1.0.4: + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-darwin-x64@1.0.4: + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linux-arm64@1.0.4: + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linux-arm@1.0.5: + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linux-s390x@1.0.4: + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linux-x64@1.0.4: + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linuxmusl-arm64@1.0.4: + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-libvips-linuxmusl-x64@1.0.4: + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-linux-arm64@0.33.5: + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + dev: false + optional: true + + /@img/sharp-linux-arm@0.33.5: + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + dev: false + optional: true + + /@img/sharp-linux-s390x@0.33.5: + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + dev: false + optional: true + + /@img/sharp-linux-x64@0.33.5: + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + dev: false + optional: true + + /@img/sharp-linuxmusl-arm64@0.33.5: + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + dev: false + optional: true + + /@img/sharp-linuxmusl-x64@0.33.5: + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + requiresBuild: true + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + dev: false + optional: true + + /@img/sharp-wasm32@0.33.5: + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + requiresBuild: true + dependencies: + '@emnapi/runtime': 1.11.3 + dev: false + optional: true + + /@img/sharp-win32-ia32@0.33.5: + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@img/sharp-win32-x64@0.33.5: + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@ioredis/as-callback@3.0.0: + resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} + dev: false + + /@ioredis/commands@1.11.0: + resolution: {integrity: sha512-tuMmOu6dtyGFv/fzCjtapCJj/zgoHaFsqs3wKsroJSRXtlLmyL/t+B7uaQiavGk1F3WWFQcUqZwk92bpp9jKcA==} dev: false /@ioredis/commands@1.2.0: resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} - dev: false - /@jridgewell/gen-mapping@0.3.3: - resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} - engines: {node: '>=6.0.0'} + /@jest/schemas@29.6.3: + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@sinclair/typebox': 0.27.12 + dev: true + + /@jridgewell/gen-mapping@0.3.13: + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} dependencies: - '@jridgewell/set-array': 1.1.2 - '@jridgewell/sourcemap-codec': 1.4.15 - '@jridgewell/trace-mapping': 0.3.18 + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 /@jridgewell/resolve-uri@3.1.0: resolution: {integrity: sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==} engines: {node: '>=6.0.0'} - /@jridgewell/set-array@1.1.2: - resolution: {integrity: sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==} - engines: {node: '>=6.0.0'} - - /@jridgewell/sourcemap-codec@1.4.14: - resolution: {integrity: sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==} + /@jridgewell/sourcemap-codec@1.6.0: + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} - /@jridgewell/sourcemap-codec@1.4.15: - resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - - /@jridgewell/trace-mapping@0.3.18: - resolution: {integrity: sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==} + /@jridgewell/trace-mapping@0.3.31: + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} dependencies: '@jridgewell/resolve-uri': 3.1.0 - '@jridgewell/sourcemap-codec': 1.4.14 - - /@lavaclient/spotify@3.1.0: - resolution: {integrity: sha512-B9AwZVyxScjJnJWJa4zMylF2i2/UOvDKL7lHWMxcezBMvOqjM+rZMr4ZTJj179qdpOaQ7zc8mBfRYSXGWJIWmA==} - engines: {node: '>=16'} - dependencies: - tslib: 2.6.2 - dev: false - - /@lavaclient/types@2.1.1: - resolution: {integrity: sha512-r69sXGyUQgqsNiDHYRm2uWuDumRydylIB0k51lKkVzFinI+DcBq2hKW3KJkImT4+YY5boQ6K7HPAO9RvhJkQDg==} + '@jridgewell/sourcemap-codec': 1.6.0 /@lavalink/encoding@0.1.2: resolution: {integrity: sha512-cUhsKYBw91+2H4wPfKCi1i9dyQmXGCcNvsNe3Sgy67ZrjsQ29hNLLEBIotBIxPMPWTFZtQcja5QFL99UNvjv0w==} @@ -940,55 +1202,50 @@ packages: base64-js: 1.5.1 dev: false - /@manypkg/cli@0.21.0: - resolution: {integrity: sha512-q/JF25il2EXtyPpc5U/Pp7TMgJot/WmFkyh7M9FiutQkliHp58UqUxIPeUObLu9EtoAp/uP21t+TMDsq1DMbeg==} - engines: {node: '>=14.18.0'} + /@manypkg/cli@0.25.1: + resolution: {integrity: sha512-lag906FyiNxzZjsRErkUD5/to174I2JzPk5bZubuJp6loMKKJn73zrtqeU7nHlVkHBg3tgXDTJj22HxUDxLRXw==} + engines: {node: '>=20.0.0'} hasBin: true dependencies: - '@manypkg/get-packages': 2.2.0 - chalk: 2.4.2 - detect-indent: 6.1.0 - find-up: 4.1.0 - fs-extra: 8.1.0 + '@manypkg/get-packages': 3.1.0 + detect-indent: 7.0.2 normalize-path: 3.0.0 - p-limit: 2.3.0 - package-json: 6.5.0 - parse-github-url: 1.0.2 - sembear: 0.5.2 - semver: 6.3.1 - spawndamnit: 2.0.0 - validate-npm-package-name: 3.0.0 - dev: false + p-limit: 6.2.0 + package-json: 10.0.1 + parse-github-url: 1.0.4 + picocolors: 1.1.1 + sembear: 0.7.0 + semver: 7.8.5 + tinyexec: 1.3.0 + validate-npm-package-name: 6.0.2 + dev: true - /@manypkg/find-root@2.2.1: - resolution: {integrity: sha512-34NlypD5mmTY65cFAK7QPgY5Tzt0qXR4ZRXdg97xAlkiLuwXUPBEXy5Hsqzd+7S2acsLxUz6Cs50rlDZQr4xUA==} - engines: {node: '>=14.18.0'} + /@manypkg/find-root@3.1.0: + resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==} + engines: {node: '>=20.0.0'} dependencies: - '@manypkg/tools': 1.1.0 - find-up: 4.1.0 - fs-extra: 8.1.0 - dev: false + '@manypkg/tools': 2.1.2 + dev: true - /@manypkg/get-packages@2.2.0: - resolution: {integrity: sha512-B5p5BXMwhGZKi/syEEAP1eVg5DZ/9LP+MZr0HqfrHLgu9fq0w4ZwH8yVen4JmjrxI2dWS31dcoswYzuphLaRxg==} - engines: {node: '>=14.18.0'} + /@manypkg/get-packages@3.1.0: + resolution: {integrity: sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg==} + engines: {node: '>=20.0.0'} dependencies: - '@manypkg/find-root': 2.2.1 - '@manypkg/tools': 1.1.0 - dev: false + '@manypkg/find-root': 3.1.0 + '@manypkg/tools': 2.1.2 + dev: true - /@manypkg/tools@1.1.0: - resolution: {integrity: sha512-SkAyKAByB9l93Slyg8AUHGuM2kjvWioUTCckT/03J09jYnfEzMO/wSXmEhnKGYs6qx9De8TH4yJCl0Y9lRgnyQ==} - engines: {node: '>=14.18.0'} + /@manypkg/tools@2.1.2: + resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==} + engines: {node: '>=20.0.0'} dependencies: - fs-extra: 8.1.0 - globby: 11.1.0 jju: 1.4.0 - read-yaml-file: 1.1.0 - dev: false + tinyglobby: 0.2.17 + yaml: 2.9.0 + dev: true - /@napi-rs/canvas-android-arm64@0.1.44: - resolution: {integrity: sha512-3UDlVf1CnibdUcM0+0xPH4L4/d/tCI895or0y7mr/Xlaa1tDmvcQCvBYl9G54IpXsm+e4T1XkVrGGJD4k1NfSg==} + /@napi-rs/canvas-android-arm64@1.0.8: + resolution: {integrity: sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==} engines: {node: '>= 10'} cpu: [arm64] os: [android] @@ -996,8 +1253,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-darwin-arm64@0.1.44: - resolution: {integrity: sha512-Y1Yx0H45Iicx2b6pcrlICjlwgylLtqi0t5OJgeUXnxLcJ1+aEpmjLr16tddqHkmGUw/nBRAwfPJrf3GaOwWowQ==} + /@napi-rs/canvas-darwin-arm64@1.0.8: + resolution: {integrity: sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -1005,8 +1262,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-darwin-x64@0.1.44: - resolution: {integrity: sha512-gbzeNz13DFH0Ak5ENyQ5ZEuSuCjNDxA/OV9P5f19lywbOVL5Ol+qgKX0BXBcP3O3IXWahruOvmmLUBn9h1MHpA==} + /@napi-rs/canvas-darwin-x64@1.0.8: + resolution: {integrity: sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -1014,8 +1271,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-linux-arm-gnueabihf@0.1.44: - resolution: {integrity: sha512-Sad3/eGyzTZiyJFeFrmX1M3aRp0n3qTAXeCm6EeAjCFGk8TWd4cINCGT3IRY4wmCvNnpe6C4fM03K07cU5YYwA==} + /@napi-rs/canvas-linux-arm-gnueabihf@1.0.8: + resolution: {integrity: sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==} engines: {node: '>= 10'} cpu: [arm] os: [linux] @@ -1023,8 +1280,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-linux-arm64-gnu@0.1.44: - resolution: {integrity: sha512-bCrI9naYGPRFHePMGN+wlrWzC+Swi6uc1YzFg4/wOYzHKSte8FXHrGspHOPPr12BCEmgg3yXK8nnLjxGdlAWtg==} + /@napi-rs/canvas-linux-arm64-gnu@1.0.8: + resolution: {integrity: sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1032,8 +1289,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-linux-arm64-musl@0.1.44: - resolution: {integrity: sha512-gB/ao9zBQaOJik4arOKJisZaG+v7DuyBW7UdG+0L80msAuJTTH2UgWOnmXfZwPxzxNbFKzOa8r48uVzfTaAHGQ==} + /@napi-rs/canvas-linux-arm64-musl@1.0.8: + resolution: {integrity: sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1041,8 +1298,17 @@ packages: dev: false optional: true - /@napi-rs/canvas-linux-x64-gnu@0.1.44: - resolution: {integrity: sha512-pvHy1bJ0DDD4Bsx6yuFnqpIyBW7+2iIK5BpvmL36zXE+7w2MEeaYzLUWTBhrXj8rzHys6MwLmHNlkw65R80YbQ==} + /@napi-rs/canvas-linux-riscv64-gnu@1.0.8: + resolution: {integrity: sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@napi-rs/canvas-linux-x64-gnu@1.0.8: + resolution: {integrity: sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1050,8 +1316,8 @@ packages: dev: false optional: true - /@napi-rs/canvas-linux-x64-musl@0.1.44: - resolution: {integrity: sha512-5QaeYqNZ/u1QI2E/UqvnmuORT6cI1qTtLosPp/y4awaK+/LXQEzotHNv0nan0z4EV/0mXsJswY9JpISRJzx+Kw==} + /@napi-rs/canvas-linux-x64-musl@1.0.8: + resolution: {integrity: sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1059,8 +1325,17 @@ packages: dev: false optional: true - /@napi-rs/canvas-win32-x64-msvc@0.1.44: - resolution: {integrity: sha512-pbeTGLox+I+sMVl/FFO21Xvp0PhijsuEr9gaynmN2X7FPTg+CCuuBDhfSU5iMAtcCCYFCk8ridZIWy5jkcf72w==} + /@napi-rs/canvas-win32-arm64-msvc@1.0.8: + resolution: {integrity: sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@napi-rs/canvas-win32-x64-msvc@1.0.8: + resolution: {integrity: sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1068,33 +1343,44 @@ packages: dev: false optional: true - /@napi-rs/canvas@0.1.44: - resolution: {integrity: sha512-IyhSndjw29LR1WqkUZvTJI4j8Ve1QGbZYtpdQjJjcFvsvJS4/WHzOWV8ZciLPJBhrYvSQf/JbZJy5LHmFV+plg==} + /@napi-rs/canvas@1.0.8: + resolution: {integrity: sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==} engines: {node: '>= 10'} optionalDependencies: - '@napi-rs/canvas-android-arm64': 0.1.44 - '@napi-rs/canvas-darwin-arm64': 0.1.44 - '@napi-rs/canvas-darwin-x64': 0.1.44 - '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.44 - '@napi-rs/canvas-linux-arm64-gnu': 0.1.44 - '@napi-rs/canvas-linux-arm64-musl': 0.1.44 - '@napi-rs/canvas-linux-x64-gnu': 0.1.44 - '@napi-rs/canvas-linux-x64-musl': 0.1.44 - '@napi-rs/canvas-win32-x64-msvc': 0.1.44 - dev: false + '@napi-rs/canvas-android-arm64': 1.0.8 + '@napi-rs/canvas-darwin-arm64': 1.0.8 + '@napi-rs/canvas-darwin-x64': 1.0.8 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.8 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.8 + '@napi-rs/canvas-linux-arm64-musl': 1.0.8 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.8 + '@napi-rs/canvas-linux-x64-gnu': 1.0.8 + '@napi-rs/canvas-linux-x64-musl': 1.0.8 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.8 + '@napi-rs/canvas-win32-x64-msvc': 1.0.8 + dev: false + + /@napi-rs/lzma-linux-x64-gnu@1.5.1: + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@next/env@14.0.3: - resolution: {integrity: sha512-7xRqh9nMvP5xrW4/+L0jgRRX+HoNRGnfJpD+5Wq6/13j3dsdzxO3BCXn7D3hMqsDb+vjZnJq+vI7+EtgrYZTeA==} + /@next/env@15.2.0: + resolution: {integrity: sha512-eMgJu1RBXxxqqnuRJQh5RozhskoNUDHBFybvi+Z+yK9qzKeG7dadhv/Vp1YooSZmCnegf7JxWuapV77necLZNA==} dev: false - /@next/eslint-plugin-next@14.0.3: - resolution: {integrity: sha512-j4K0n+DcmQYCVnSAM+UByTVfIHnYQy2ODozfQP+4RdwtRDfobrIvKq1K4Exb2koJ79HSSa7s6B2SA8T/1YR3RA==} + /@next/eslint-plugin-next@15.2.0: + resolution: {integrity: sha512-jHFUG2OwmAuOASqq253RAEG/5BYcPHn27p1NoWZDCf4OdvdK0yRYWX92YKkL+Mk2s+GyJrmd/GATlL5b2IySpw==} dependencies: - glob: 7.1.7 + fast-glob: 3.3.1 dev: false - /@next/swc-darwin-arm64@14.0.3: - resolution: {integrity: sha512-64JbSvi3nbbcEtyitNn2LEDS/hcleAFpHdykpcnrstITFlzFgB/bW0ER5/SJJwUPj+ZPY+z3e+1jAfcczRLVGw==} + /@next/swc-darwin-arm64@15.2.0: + resolution: {integrity: sha512-rlp22GZwNJjFCyL7h5wz9vtpBVuCt3ZYjFWpEPBGzG712/uL1bbSkS675rVAUCRZ4hjoTJ26Q7IKhr5DfJrHDA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -1102,8 +1388,8 @@ packages: dev: false optional: true - /@next/swc-darwin-x64@14.0.3: - resolution: {integrity: sha512-RkTf+KbAD0SgYdVn1XzqE/+sIxYGB7NLMZRn9I4Z24afrhUpVJx6L8hsRnIwxz3ERE2NFURNliPjJ2QNfnWicQ==} + /@next/swc-darwin-x64@15.2.0: + resolution: {integrity: sha512-DiU85EqSHogCz80+sgsx90/ecygfCSGl5P3b4XDRVZpgujBm5lp4ts7YaHru7eVTyZMjHInzKr+w0/7+qDrvMA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -1111,8 +1397,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-gnu@14.0.3: - resolution: {integrity: sha512-3tBWGgz7M9RKLO6sPWC6c4pAw4geujSwQ7q7Si4d6bo0l6cLs4tmO+lnSwFp1Tm3lxwfMk0SgkJT7EdwYSJvcg==} + /@next/swc-linux-arm64-gnu@15.2.0: + resolution: {integrity: sha512-VnpoMaGukiNWVxeqKHwi8MN47yKGyki5q+7ql/7p/3ifuU2341i/gDwGK1rivk0pVYbdv5D8z63uu9yMw0QhpQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1120,8 +1406,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-musl@14.0.3: - resolution: {integrity: sha512-v0v8Kb8j8T23jvVUWZeA2D8+izWspeyeDGNaT2/mTHWp7+37fiNfL8bmBWiOmeumXkacM/AB0XOUQvEbncSnHA==} + /@next/swc-linux-arm64-musl@15.2.0: + resolution: {integrity: sha512-ka97/ssYE5nPH4Qs+8bd8RlYeNeUVBhcnsNUmFM6VWEob4jfN9FTr0NBhXVi1XEJpj3cMfgSRW+LdE3SUZbPrw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -1129,8 +1415,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-gnu@14.0.3: - resolution: {integrity: sha512-VM1aE1tJKLBwMGtyBR21yy+STfl0MapMQnNrXkxeyLs0GFv/kZqXS5Jw/TQ3TSUnbv0QPDf/X8sDXuMtSgG6eg==} + /@next/swc-linux-x64-gnu@15.2.0: + resolution: {integrity: sha512-zY1JduE4B3q0k2ZCE+DAF/1efjTXUsKP+VXRtrt/rJCTgDlUyyryx7aOgYXNc1d8gobys/Lof9P9ze8IyRDn7Q==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1138,8 +1424,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-musl@14.0.3: - resolution: {integrity: sha512-64EnmKy18MYFL5CzLaSuUn561hbO1Gk16jM/KHznYP3iCIfF9e3yULtHaMy0D8zbHfxset9LTOv6cuYKJgcOxg==} + /@next/swc-linux-x64-musl@15.2.0: + resolution: {integrity: sha512-QqvLZpurBD46RhaVaVBepkVQzh8xtlUN00RlG4Iq1sBheNugamUNPuZEH1r9X1YGQo1KqAe1iiShF0acva3jHQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -1147,8 +1433,8 @@ packages: dev: false optional: true - /@next/swc-win32-arm64-msvc@14.0.3: - resolution: {integrity: sha512-WRDp8QrmsL1bbGtsh5GqQ/KWulmrnMBgbnb+59qNTW1kVi1nG/2ndZLkcbs2GX7NpFLlToLRMWSQXmPzQm4tog==} + /@next/swc-win32-arm64-msvc@15.2.0: + resolution: {integrity: sha512-ODZ0r9WMyylTHAN6pLtvUtQlGXBL9voljv6ujSlcsjOxhtXPI1Ag6AhZK0SE8hEpR1374WZZ5w33ChpJd5fsjw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -1156,17 +1442,8 @@ packages: dev: false optional: true - /@next/swc-win32-ia32-msvc@14.0.3: - resolution: {integrity: sha512-EKffQeqCrj+t6qFFhIFTRoqb2QwX1mU7iTOvMyLbYw3QtqTw9sMwjykyiMlZlrfm2a4fA84+/aeW+PMg1MjuTg==} - engines: {node: '>= 10'} - cpu: [ia32] - os: [win32] - requiresBuild: true - dev: false - optional: true - - /@next/swc-win32-x64-msvc@14.0.3: - resolution: {integrity: sha512-ERhKPSJ1vQrPiwrs15Pjz/rvDHZmkmvbf/BjPN/UCOI++ODftT0GtasDPi0j+y6PPJi5HsXw+dpRaXUaw4vjuQ==} + /@next/swc-win32-x64-msvc@15.2.0: + resolution: {integrity: sha512-8+4Z3Z7xa13NdUuUAcpVNA6o76lNPniBd9Xbo02bwXQXnZgFvEopwY2at5+z7yHl47X9qbZpvwatZ2BRo3EdZw==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1192,12 +1469,37 @@ packages: '@nodelib/fs.scandir': 2.1.5 fastq: 1.15.0 - /@panva/hkdf@1.1.1: - resolution: {integrity: sha512-dhPeilub1NuIG0X5Kvhh9lH4iW3ZsHlnzwgwbOlgwQ2wG1IqFzsgHqmKPk3WzsdWAeaxKJxgM0+W433RmN45GA==} + /@panva/hkdf@1.2.1: + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} dev: false - /@prisma/client@5.6.0(prisma@5.6.0): - resolution: {integrity: sha512-mUDefQFa1wWqk4+JhKPYq8BdVoFk9NFMBXUI8jAkBfQTtgx8WPx02U2HB/XbAz3GSUJpeJOKJQtNvaAIDs6sug==} + /@pinojs/redact@0.4.0: + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + dev: false + + /@pnpm/config.env-replace@1.1.0: + resolution: {integrity: sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==} + engines: {node: '>=12.22.0'} + dev: true + + /@pnpm/network.ca-file@1.0.2: + resolution: {integrity: sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==} + engines: {node: '>=12.22.0'} + dependencies: + graceful-fs: 4.2.10 + dev: true + + /@pnpm/npm-conf@3.0.3: + resolution: {integrity: sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==} + engines: {node: '>=12'} + dependencies: + '@pnpm/config.env-replace': 1.1.0 + '@pnpm/network.ca-file': 1.0.2 + config-chain: 1.1.13 + dev: true + + /@prisma/client@5.22.0(prisma@5.22.0): + resolution: {integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==} engines: {node: '>=16.13'} requiresBuild: true peerDependencies: @@ -1206,664 +1508,876 @@ packages: prisma: optional: true dependencies: - '@prisma/engines-version': 5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee - prisma: 5.6.0 + prisma: 5.22.0 dev: false - /@prisma/engines-version@5.6.0-32.e95e739751f42d8ca026f6b910f5a2dc5adeaeee: - resolution: {integrity: sha512-UoFgbV1awGL/3wXuUK3GDaX2SolqczeeJ5b4FVec9tzeGbSWJboPSbT0psSrmgYAKiKnkOPFSLlH6+b+IyOwAw==} - dev: false + /@prisma/debug@5.22.0: + resolution: {integrity: sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==} - /@prisma/engines@5.6.0: - resolution: {integrity: sha512-Mt2q+GNJpU2vFn6kif24oRSBQv1KOkYaterQsi0k2/lA+dLvhRX6Lm26gon6PYHwUM8/h8KRgXIUMU0PCLB6bw==} + /@prisma/engines-version@5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2: + resolution: {integrity: sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==} + + /@prisma/engines@5.22.0: + resolution: {integrity: sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==} requiresBuild: true + dependencies: + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/fetch-engine': 5.22.0 + '@prisma/get-platform': 5.22.0 - /@radix-ui/number@1.0.1: - resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==} + /@prisma/fetch-engine@5.22.0: + resolution: {integrity: sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==} dependencies: - '@babel/runtime': 7.22.6 - dev: false + '@prisma/debug': 5.22.0 + '@prisma/engines-version': 5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2 + '@prisma/get-platform': 5.22.0 - /@radix-ui/primitive@1.0.1: - resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} + /@prisma/get-platform@5.22.0: + resolution: {integrity: sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==} dependencies: - '@babel/runtime': 7.22.6 + '@prisma/debug': 5.22.0 + + /@radix-ui/number@1.1.3: + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + dev: false + + /@radix-ui/primitive@1.1.7: + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} dev: false - /@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==} + /@radix-ui/react-arrow@1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} + /@radix-ui/react-collection@1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-slot': 1.0.2(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} + /@radix-ui/react-compose-refs@1.1.5(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 + '@types/react': 18.3.31 + react: 18.3.1 dev: false - /@radix-ui/react-context@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} + /@radix-ui/react-context@1.2.2(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 + '@types/react': 18.3.31 + react: 18.3.1 dev: false - /@radix-ui/react-direction@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==} + /@radix-ui/react-direction@1.1.4(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 + '@types/react': 18.3.31 + react: 18.3.1 dev: false - /@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==} + /@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-dropdown-menu@2.0.6(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==} + /@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-id': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-menu': 2.0.6(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-focus-guards@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-focus-guards@1.1.6(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 + '@types/react': 18.3.31 + react: 18.3.1 dev: false - /@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==} + /@radix-ui/react-focus-scope@1.1.16(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-id@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} + /@radix-ui/react-id@1.1.4(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - react: 18.2.0 + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + react: 18.3.1 dev: false - /@radix-ui/react-menu@2.0.6(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==} + /@radix-ui/react-menu@2.1.24(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) + dev: false + + /@radix-ui/react-popper@1.3.7(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-direction': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-id': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-slot': 1.0.2(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - aria-hidden: 1.2.3 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.5.5(@types/react@18.2.38)(react@18.2.0) - dev: false - - /@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==} + '@floating-ui/react-dom': 2.0.1(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-rect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/rect': 1.1.3 + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-portal@1.1.17(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@floating-ui/react-dom': 2.0.1(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-rect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/rect': 1.0.1 - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-portal@1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==} + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-presence@1.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-presence@1.0.1(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} + /@radix-ui/react-primitive@2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) dev: false - /@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} + /@radix-ui/react-roving-focus@1.1.19(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-select@2.3.7(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-WFGImkmbzcfxeIwq/+4HvRN0pizBwbwQUED4I13ezQsDdfl38ZntN6TmR8XaSzPBqoCToe8rF75j6NPNDSzhbg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-slot': 1.0.2(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-direction': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-id': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': 1.3.3(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-previous': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + aria-hidden: 1.2.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) + dev: false + + /@radix-ui/react-slot@1.3.3(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + react: 18.3.1 dev: false - /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} + /@radix-ui/react-switch@1.3.7(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-48tB/4dn2UVLBCYhTu9AuR63IHl73l/qLbLgxd86noTUor4/K4LFDAcYjK+isP5313qxaFpjPVogE7+Y0/V3Kw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-direction': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-id': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-select@2.0.0(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==} + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-size': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-toast@1.2.23(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-ofhyAsYaocRGOs/n0XWdUOSVzEAG6BfrMVM8z0c0kLEWY38w/0WuMFPTJP/HVaZPYkMvHZoKIIhNcjbTCBILPg==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true '@types/react-dom': optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/number': 1.0.1 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-direction': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-id': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-slot': 1.0.2(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - aria-hidden: 1.2.3 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.5.5(@types/react@18.2.38)(react@18.2.0) - dev: false - - /@radix-ui/react-slot@1.0.2(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-context': 1.2.2(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-use-callback-ref@1.1.4(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.31 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-controllable-state@1.2.6(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-effect-event@0.0.5(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-is-hydrated@0.1.3(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.31 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-layout-effect@1.1.4(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.31 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-previous@1.1.4(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.31 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-rect@1.1.4(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/rect': 1.1.3 + '@types/react': 18.3.31 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-size@1.1.4(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@types/react': 18.3.31 + react: 18.3.1 + dev: false + + /@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true + '@types/react-dom': + optional: true dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - react: 18.2.0 + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@18.3.7)(@types/react@18.3.31)(react-dom@18.3.1)(react@18.3.1) + '@types/react': 18.3.31 + '@types/react-dom': 18.3.7(@types/react@18.3.31) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/rect@1.1.3: + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} dev: false - /@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-toast@1.1.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/primitive': 1.0.1 - '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-context': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - - /@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rollup/rollup-android-arm-eabi@4.63.1: + resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-android-arm64@4.63.1: + resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-arm64@4.63.1: + resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-x64@4.63.1: + resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-freebsd-arm64@4.63.1: + resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-freebsd-x64@4.63.1: + resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm-gnueabihf@4.63.1: + resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm-musleabihf@4.63.1: + resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-gnu@4.63.1: + resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-musl@4.63.1: + resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-loong64-gnu@4.63.1: + resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-loong64-musl@4.63.1: + resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-ppc64-gnu@4.63.1: + resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-ppc64-musl@4.63.1: + resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-riscv64-gnu@4.63.1: + resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-riscv64-musl@4.63.1: + resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-s390x-gnu@4.63.1: + resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rollup/rollup-linux-x64-gnu@4.63.1: + resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rollup/rollup-linux-x64-musl@4.63.1: + resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rollup/rollup-openbsd-x64@4.63.1: + resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rollup/rollup-openharmony-arm64@4.63.1: + resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-rect@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/rect': 1.0.1 - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rollup/rollup-win32-arm64-msvc@4.63.1: + resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-use-size@1.0.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==} - peerDependencies: - '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.38)(react@18.2.0) - '@types/react': 18.2.38 - react: 18.2.0 - dev: false + /@rollup/rollup-win32-ia32-msvc@4.63.1: + resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true - /@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.22.6 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.16)(@types/react@18.2.38)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.38 - '@types/react-dom': 18.2.16 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false + /@rollup/rollup-win32-x64-gnu@4.63.1: + resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true - /@radix-ui/rect@1.0.1: - resolution: {integrity: sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==} - dependencies: - '@babel/runtime': 7.22.6 + /@rollup/rollup-win32-x64-msvc@4.63.1: + resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rtsao/scc@1.1.0: + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} dev: false - /@sapphire/async-queue@1.5.0: - resolution: {integrity: sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==} + /@sapphire/async-queue@1.5.5: + resolution: {integrity: sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false - /@sapphire/cron@1.1.1: - resolution: {integrity: sha512-SBQepfBkwCzYBqMfYB+lrfx7AK6zVdT4lK7X4Q0SthxYS82MYw6qAiRUd24bzhaXc33KBk7g7Uljbxu98qDDJw==} + /@sapphire/cron@1.2.1: + resolution: {integrity: sha512-K96GX4UkzgC/Y2VHXVjhM2Bl4D04552nr/fDiOj9bOACW1+wqeFfLJ3eV6jleTSXmzPIwDvkPIUJLf8A5KSD+w==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: - '@sapphire/utilities': 3.13.0 + '@sapphire/utilities': 3.18.2 dev: false - /@sapphire/decorators@6.0.2: - resolution: {integrity: sha512-R0bsVvvT/iclElvdglpneIB6UGhzqT3DbMy8b0VHcjSSWArAfxFXiv7mVO/5VeiQduZFhWPgqtTWayKdRYY1NA==} + /@sapphire/decorators@6.2.0: + resolution: {integrity: sha512-st1DNDCNoZaZYz3fgCA99W87Bhe6XqM8y0G+Z9NBOBEqvOYkVNGcPMDrMiaZrPD9Z471k8fpLqJVUNSuWUx8Hg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: - tslib: 2.6.2 + tslib: 2.8.1 dev: false /@sapphire/discord-utilities@3.2.0: resolution: {integrity: sha512-gKgTkWIBgkG0c+V3ALXeoD7XeciAYQtHNewjltSMaxCLF/wsy6NFj6xxqdEeSJMF40tcfUJ7F44r2DR5r3K8Eg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: - discord-api-types: 0.37.64 + discord-api-types: 0.37.120 + dev: false + + /@sapphire/discord-utilities@3.5.0: + resolution: {integrity: sha512-H4SY5KTVDZrqA5QG7ob6etwqhdOb3TRSY2wv56f0tiobUdIr0irlrYvdmr8Kg/FRxWU+aiHDIISWGG5vBuxOGw==} + engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + dependencies: + discord-api-types: 0.38.54 dev: false - /@sapphire/discord.js-utilities@7.1.2: - resolution: {integrity: sha512-Ly/mtykmX7lak4+fzVbDvch0xnlAwDIDGZGg9mGuZVHdA4sINWcVoCNulI+OqoZWdLCfUKph32KvXGESzriD7A==} + /@sapphire/discord.js-utilities@7.3.3: + resolution: {integrity: sha512-WDj+zjWgNCUSvzYDD0wY3TVeTUseHq0Nhk0wVWxSDjY8z2gFEVcpY7wF8/fbTDWP44LUG5sUQ4haIrIj2OjmkQ==} engines: {node: '>=16.6.0', npm: '>=7.0.0'} dependencies: - '@sapphire/discord-utilities': 3.2.0 - '@sapphire/duration': 1.1.0 - '@sapphire/utilities': 3.13.0 - tslib: 2.6.2 + '@sapphire/discord-utilities': 3.5.0 + '@sapphire/duration': 1.2.0 + '@sapphire/utilities': 3.18.2 + tslib: 2.8.1 dev: false - /@sapphire/duration@1.1.0: - resolution: {integrity: sha512-ATb2pWPLcSgG7bzvT6MglUcDexFSufr2FLXUmhipWGFtZbvDhkopGBIuHyzoGy7LZvL8UY5T6pRLNdFv5pl/Lg==} + /@sapphire/duration@1.2.0: + resolution: {integrity: sha512-LxjOAFXz81WmrI8XX9YaVcAZDjQj/1p78lZCvkAWZB1nphOwz/D0dU3CBejmhOWx5dO5CszTkLJMNR0xuCK+Zg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false @@ -1873,13 +2387,13 @@ packages: dependencies: '@discordjs/builders': 1.7.0 '@sapphire/discord-utilities': 3.2.0 - '@sapphire/discord.js-utilities': 7.1.2 + '@sapphire/discord.js-utilities': 7.3.3 '@sapphire/lexure': 1.1.5 '@sapphire/pieces': 3.10.0 '@sapphire/ratelimits': 2.4.7 '@sapphire/result': 2.6.4 '@sapphire/stopwatch': 1.5.0 - '@sapphire/utilities': 3.13.0 + '@sapphire/utilities': 3.18.2 dev: false /@sapphire/lexure@1.1.5: @@ -1894,8 +2408,8 @@ packages: engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: '@discordjs/collection': 1.5.3 - '@sapphire/utilities': 3.13.0 - tslib: 2.6.2 + '@sapphire/utilities': 3.18.2 + tslib: 2.8.1 dev: false /@sapphire/plugin-hmr@2.0.3: @@ -1923,8 +2437,16 @@ packages: lodash: 4.17.21 dev: false - /@sapphire/snowflake@3.5.1: - resolution: {integrity: sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==} + /@sapphire/shapeshift@4.0.0: + resolution: {integrity: sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==} + engines: {node: '>=v16'} + dependencies: + fast-deep-equal: 3.1.3 + lodash: 4.17.21 + dev: false + + /@sapphire/snowflake@3.5.5: + resolution: {integrity: sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false @@ -1932,191 +2454,200 @@ packages: resolution: {integrity: sha512-DtyKugdy3JTqm6JnEepTY64fGJAqlusDVrlrzifEgSCfGYCqpvB+SBldkWtDH+z+zLcp+PyaFLq7xpVfkhmvGg==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: - tslib: 2.6.2 + tslib: 2.8.1 dev: false - /@sapphire/time-utilities@1.7.10: - resolution: {integrity: sha512-icmuse7m3oGJXRtweTmTT6vMMtCpWwGCpzephI5K8aQQRsfZwKYA+jAriSnT4+Lfw6LcR8j7TfkAAX7SyOOggQ==} + /@sapphire/time-utilities@1.7.14: + resolution: {integrity: sha512-UVJQ9oyzXcmJVVf9Y9ucGzRmKoPxe6SehpQlBNTX7CTVu0aj1lrEGL+bBa0Mu7hxF5DYXYjDYeO1e5BR0eg90Q==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dependencies: - '@sapphire/cron': 1.1.1 - '@sapphire/duration': 1.1.0 - '@sapphire/timer-manager': 1.0.0 - '@sapphire/timestamp': 1.0.1 + '@sapphire/cron': 1.2.1 + '@sapphire/duration': 1.2.0 + '@sapphire/timer-manager': 1.0.4 + '@sapphire/timestamp': 1.0.5 dev: false - /@sapphire/timer-manager@1.0.0: - resolution: {integrity: sha512-vxxnv75QPMGKt6IB6nL2xRJfwzcUQ9DBGzJLg6G8eS5O4u7j3IR/yr/GQsa4gIpjw6kQOgn8lUdnSTlpnERTbQ==} + /@sapphire/timer-manager@1.0.4: + resolution: {integrity: sha512-gc1JW8oui86f2l0T/1Iwd7hZTgus8b46slitTR2y0+wiMAEYxdp6vVTHvMAEHOqX8drkxK70aGfZmQKbAcgFqQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false - /@sapphire/timestamp@1.0.1: - resolution: {integrity: sha512-uLg+rBFuBiaQY/pFGDDzZSOH2cfv4ONIB7zQGNuRCTpYKBW/iIhRBIZjJZyn8NVkXQhVi+Q94DI4i6gDhYVs7w==} + /@sapphire/timestamp@1.0.5: + resolution: {integrity: sha512-oNwWyNdbt5wm4aYZvlHl1+64U3g0xrFmRIHsnER7RgMxNnp/wmAE4yTK2oUHeadg3t4V9iYctPAQCF+aINke4g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false - /@sapphire/ts-config@5.0.0: - resolution: {integrity: sha512-E4JfpCK/OxaH8lYEP0+xbRUeIBanEOkA5IUtvj+Uib2TG2p7H0Sb1mF1fpmf0ICyDaOu9/201Il/ymV3cr/isw==} + /@sapphire/ts-config@5.0.3: + resolution: {integrity: sha512-bFyGYHFT3TpOf5Sg2P+zY2ad0t5IA2epc5HtewlghhL7MYvbZvxtKsdaNaMwAdNObBx7hpiQm5OcOhyzEwQvbQ==} engines: {node: '>=v16.0.0', npm: '>=8.0.0'} dependencies: - tslib: 2.6.2 - typescript: 5.3.2 + tslib: 2.8.1 + typescript: 5.4.5 dev: true - /@sapphire/utilities@3.13.0: - resolution: {integrity: sha512-BD5ycPjZX5dXxrAb90dJTY8ukpPVBXgU17gA5ghK2memS4hwAzFYpvK+R+6zh4d6HYIKVuqrVhGXjvZenAa/Aw==} - engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + /@sapphire/utilities@3.18.2: + resolution: {integrity: sha512-QGLdC9+pT74Zd7aaObqn0EUfq40c4dyTL65pFnkM6WO1QYN7Yg/s4CdH+CXmx0Zcu6wcfCWILSftXPMosJHP5A==} + engines: {node: '>=v14.0.0'} dev: false - /@sindresorhus/is@0.14.0: - resolution: {integrity: sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==} - engines: {node: '>=6'} - dev: false + /@sinclair/typebox@0.27.12: + resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==} + dev: true - /@swc/helpers@0.5.2: - resolution: {integrity: sha512-E4KcWTpoLHqwPHLxidpOqQbcrZVgi0rsmmZXUle1jXmJfuIf/UWpczUJ7MZZ5tlxytgJXyp0w4PGkkeLiuIdZw==} + /@so-ric/colorspace@1.1.6: + resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} dependencies: - tslib: 2.6.2 + color: 5.0.3 + text-hex: 1.0.0 dev: false - /@szmarczak/http-timer@1.1.2: - resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==} - engines: {node: '>=6'} + /@swc/counter@0.1.3: + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + dev: false + + /@swc/helpers@0.5.15: + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} dependencies: - defer-to-connect: 1.1.3 + tslib: 2.8.1 dev: false - /@t3-oss/env-core@0.7.1(typescript@5.3.2)(zod@3.22.4): - resolution: {integrity: sha512-3+SQt39OlmSaRLqYVFv8uRm1BpFepM5TIiMytRqO9cjH+wB77o6BIJdeyM5h5U4qLBMEzOJWCY4MBaU/rLwbYw==} + /@t3-oss/env-core@0.13.11(typescript@5.9.3)(zod@3.24.4): + resolution: {integrity: sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ==} peerDependencies: - typescript: '>=4.7.2' - zod: ^3.0.0 + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0 peerDependenciesMeta: + arktype: + optional: true typescript: optional: true + valibot: + optional: true + zod: + optional: true dependencies: - typescript: 5.3.2 - zod: 3.22.4 + typescript: 5.9.3 + zod: 3.24.4 dev: false - /@t3-oss/env-nextjs@0.7.1(typescript@5.3.2)(zod@3.22.4): - resolution: {integrity: sha512-tQDbNLGCOvKGi+JoGuJ/CJInJI7/kLWJqtgGppAKS7ZFLdVOqZYR/uRjxlXOWPnxmUKF8VswOAsq7fXUpNZDhA==} + /@t3-oss/env-nextjs@0.13.11(typescript@5.9.3)(zod@3.24.4): + resolution: {integrity: sha512-NC+3j7YWgpzdFu1t5y/8wqibTK0lm5RS4bjXA1n8uwik3wIR4iZM4Fa+U2BaMa5k3Qk8RZiYhoAIX0WogmGkzg==} peerDependencies: - typescript: '>=4.7.2' - zod: ^3.0.0 + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0 peerDependenciesMeta: + arktype: + optional: true typescript: optional: true + valibot: + optional: true + zod: + optional: true dependencies: - '@t3-oss/env-core': 0.7.1(typescript@5.3.2)(zod@3.22.4) - typescript: 5.3.2 - zod: 3.22.4 + '@t3-oss/env-core': 0.13.11(typescript@5.9.3)(zod@3.24.4) + typescript: 5.9.3 + zod: 3.24.4 dev: false - /@tanstack/query-core@5.8.3: - resolution: {integrity: sha512-SWFMFtcHfttLYif6pevnnMYnBvxKf3C+MHMH7bevyYfpXpTMsLB9O6nNGBdWSoPwnZRXFNyNeVZOw25Wmdasow==} + /@tanstack/query-core@5.102.8: + resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==} dev: false - /@tanstack/query-devtools@5.8.4: - resolution: {integrity: sha512-F1dRbITNt9tMUoM9WCH8WQ2c54116hv52m/PKK8ZiN/pO2wGVzTZtKuLanF8pFpwmNchjIixcMw/a57HY5ivcw==} - dev: false - - /@tanstack/react-query-devtools@5.8.4(@tanstack/react-query@5.8.4)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-mffs51FJqXU/5rwhbwv393DccL6et7uK2pRLwOcmMrWbPyW8vpxr9oidaghHX4cdVeP/7u5owW9yMpBhBAJfcQ==} - peerDependencies: - '@tanstack/react-query': ^5.8.4 - react: ^18.0.0 - react-dom: ^18.0.0 - dependencies: - '@tanstack/query-devtools': 5.8.4 - '@tanstack/react-query': 5.8.4(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + /@tanstack/query-devtools@5.102.8: + resolution: {integrity: sha512-ZgeMKuF5d/zOE+tgWm3cSbD6Zcbr6IugVsnbHzUcSismqLZDSUPBKM6ILUxExgwe6rPAOox2x5bA5T+PSOQG0Q==} dev: false - /@tanstack/react-query-next-experimental@5.8.4(@tanstack/react-query@5.8.4)(next@14.0.3)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-+FfKNLOcjXyFUZHr5z2Wlm/7vJ9VCZUa3ajeOz/1awGSUuGaMyvHGYMO8Pk9YKxg7Fd/lymp1gjOccJcs3vc6g==} + /@tanstack/react-query-devtools@5.102.8(@tanstack/react-query@5.102.8)(react@18.3.1): + resolution: {integrity: sha512-QKb7A44BZOU7nxsGA4gFN1fofjYovar5O0T83Ff4Y+2eRq09RGFrAzzutVdF/6/emfSaMDohp6e759BjB3fxEw==} peerDependencies: - '@tanstack/react-query': ^5.8.4 - next: ^13 || ^14 - react: ^18.0.0 - react-dom: ^18.0.0 + '@tanstack/react-query': ^5.102.8 + react: ^18 || ^19 dependencies: - '@tanstack/react-query': 5.8.4(react-dom@18.2.0)(react@18.2.0) - next: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@tanstack/query-devtools': 5.102.8 + '@tanstack/react-query': 5.102.8(react@18.3.1) + react: 18.3.1 dev: false - /@tanstack/react-query@5.8.4(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-CD+AkXzg8J72JrE6ocmuBEJfGzEzu/bzkD6sFXFDDB5yji9N20JofXZlN6n0+CaPJuIi+e4YLCbGsyPFKkfNQA==} + /@tanstack/react-query@5.102.8(react@18.3.1): + resolution: {integrity: sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==} peerDependencies: - react: ^18.0.0 - react-dom: ^18.0.0 - react-native: '*' - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true + react: ^18 || ^19 dependencies: - '@tanstack/query-core': 5.8.3 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@tanstack/query-core': 5.102.8 + react: 18.3.1 dev: false - /@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106): - resolution: {integrity: sha512-OxgbvwoWgWpijxhtovG4eO9hA+ov/WWtHuwXMwhNt1Jsr5HtHqYnCkU0vaQceponGlvQVPzLYi3zZ7oqQwPFLQ==} + /@trpc/client@11.18.0(@trpc/server@11.18.0)(typescript@5.9.3): + resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==} + hasBin: true peerDependencies: - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 + '@trpc/server': 11.18.0 + typescript: '>=5.7.2' dependencies: - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106 + '@trpc/server': 11.18.0(typescript@5.9.3) + typescript: 5.9.3 dev: false - /@trpc/next@11.0.0-alpha-next-2023-11-21-11-13-12.106(@tanstack/react-query@5.8.4)(@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/react-query@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106)(next@14.0.3)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-RcXjvtSYqL241B45ELp1r/k4sFMn2EFxoDL7eT6kC+fIh+gSUNZItDD78Xx/sOMB6KqhAbAjnuYeWP+V8TtBzA==} + /@trpc/next@11.18.0(@tanstack/react-query@5.102.8)(@trpc/client@11.18.0)(@trpc/react-query@11.18.0)(@trpc/server@11.18.0)(next@15.2.0)(react-dom@18.3.1)(react@18.3.1)(typescript@5.9.3): + resolution: {integrity: sha512-ocwbruAWMGX9hY3HFg86X4jAcoF2v+xx+A2jDn72SbttRRG2hXR+XKPjrLc1dDJC0oi+/2DJEbL14+k1pyY5og==} + hasBin: true peerDependencies: - '@tanstack/react-query': ^5.0.0 - '@trpc/client': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 - '@trpc/react-query': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 + '@tanstack/react-query': ^5.59.15 + '@trpc/client': 11.18.0 + '@trpc/react-query': 11.18.0 + '@trpc/server': 11.18.0 next: '*' react: '>=16.8.0' react-dom: '>=16.8.0' + typescript: '>=5.7.2' + peerDependenciesMeta: + '@tanstack/react-query': + optional: true + '@trpc/react-query': + optional: true dependencies: - '@tanstack/react-query': 5.8.4(react-dom@18.2.0)(react@18.2.0) - '@trpc/client': 11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106) - '@trpc/react-query': 11.0.0-alpha-next-2023-11-21-11-13-12.106(@tanstack/react-query@5.8.4)(@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106)(react-dom@18.2.0)(react@18.2.0) - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106 - next: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - react-ssr-prepass: 1.5.0(react@18.2.0) + '@tanstack/react-query': 5.102.8(react@18.3.1) + '@trpc/client': 11.18.0(@trpc/server@11.18.0)(typescript@5.9.3) + '@trpc/react-query': 11.18.0(@tanstack/react-query@5.102.8)(@trpc/client@11.18.0)(@trpc/server@11.18.0)(react@18.3.1)(typescript@5.9.3) + '@trpc/server': 11.18.0(typescript@5.9.3) + next: 15.2.0(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + typescript: 5.9.3 dev: false - /@trpc/react-query@11.0.0-alpha-next-2023-11-21-11-13-12.106(@tanstack/react-query@5.8.4)(@trpc/client@11.0.0-alpha-next-2023-11-21-11-13-12.106)(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-5oJmgYykcgFwRYEWTm+dzrbK90qxMcT0jvsNyfwJF+Bv47dsZ+MhrvHYhLPzEbiFF594lsgrarAE2ljYj8Im2A==} + /@trpc/react-query@11.18.0(@tanstack/react-query@5.102.8)(@trpc/client@11.18.0)(@trpc/server@11.18.0)(react@18.3.1)(typescript@5.9.3): + resolution: {integrity: sha512-C1+Wwm2pCeUJucI+bnFpxGYjNuvV+ko1BC1T9tUxBVdrhHRCdn9ubxdevdLSAa49XRJRJiZnSuzl3Ys/yvs1vg==} peerDependencies: - '@tanstack/react-query': ^5.0.0 - '@trpc/client': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106+fd86afd65 - react: '>=16.8.0' - react-dom: '>=16.8.0' + '@tanstack/react-query': ^5.80.3 + '@trpc/client': 11.18.0 + '@trpc/server': 11.18.0 + react: '>=18.2.0' + typescript: '>=5.7.2' dependencies: - '@tanstack/react-query': 5.8.4(react-dom@18.2.0)(react@18.2.0) - '@trpc/client': 11.0.0-alpha-next-2023-11-21-11-13-12.106(@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106) - '@trpc/server': 11.0.0-alpha-next-2023-11-21-11-13-12.106 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + '@tanstack/react-query': 5.102.8(react@18.3.1) + '@trpc/client': 11.18.0(@trpc/server@11.18.0)(typescript@5.9.3) + '@trpc/server': 11.18.0(typescript@5.9.3) + react: 18.3.1 + typescript: 5.9.3 dev: false - /@trpc/server@11.0.0-alpha-next-2023-11-21-11-13-12.106: - resolution: {integrity: sha512-txzg8RTrZhkTaYOE1vXa99iKDAT6A31e8T34KCpaxY752Wzv9/A9F9AFq+NDN3+PqlIPoCvmUp38dN2BLPk3SQ==} - engines: {node: '>=18.0.0'} + /@trpc/server@11.18.0(typescript@5.9.3): + resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==} + hasBin: true + peerDependencies: + typescript: '>=5.7.2' + dependencies: + typescript: 5.9.3 dev: false - /@types/eslint@8.44.7: - resolution: {integrity: sha512-f5ORu2hcBbKei97U73mf+l9t4zTGl74IqZ0GQk4oVea/VS8tQZYkUveSYojk+frraAVYId0V2WC9O4PTNru2FQ==} + /@types/eslint@8.56.12: + resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==} dependencies: '@types/estree': 1.0.1 '@types/json-schema': 7.0.12 @@ -2124,14 +2655,19 @@ packages: /@types/estree@1.0.1: resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} - dev: false - /@types/ioredis@4.28.10: - resolution: {integrity: sha512-69LyhUgrXdgcNDv7ogs1qXZomnfOEnSmrmMFqKgt1XMJxmoOSG/u3wYy13yACIfKuMJ8IhKgHafDO3sx19zVQQ==} - dependencies: - '@types/node': 20.9.3 + /@types/estree@1.0.9: + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} dev: true + /@types/ioredis-mock@8.2.5: + resolution: {integrity: sha512-cZyuwC9LGtg7s5G9/w6rpy3IOZ6F/hFR0pQlWYZESMo1xQUYbDpa6haqB4grTePjsGzcB/YLBFCjqRunK5wieg==} + dependencies: + '@types/node': 20.19.43 + ioredis: 5.6.1 + transitivePeerDependencies: + - supports-color + /@types/json-schema@7.0.12: resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} @@ -2139,44 +2675,26 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: false - /@types/keyv@3.1.4: - resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} - dependencies: - '@types/node': 20.9.3 - dev: false - - /@types/node@20.9.3: - resolution: {integrity: sha512-nk5wXLAXGBKfrhLB0cyHGbSqopS+nz0BUgZkUQqSHSSgdee0kssp1IAqlQOu333bW+gMNs2QREx7iynm19Abxw==} + /@types/node@20.19.43: + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} dependencies: - undici-types: 5.26.5 + undici-types: 6.21.0 /@types/prop-types@15.7.5: resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} - /@types/react-dom@18.2.16: - resolution: {integrity: sha512-766c37araZ9vxtYs25gvY2wNdFWsT2ZiUvOd0zMhTaoGj6B911N8CKQWgXXJoPMLF3J82thpRqQA7Rf3rBwyJw==} + /@types/react-dom@18.3.7(@types/react@18.3.31): + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 dependencies: - '@types/react': 18.2.38 + '@types/react': 18.3.31 - /@types/react@18.2.38: - resolution: {integrity: sha512-cBBXHzuPtQK6wNthuVMV6IjHAFkdl/FOPFIlkd81/Cd1+IqkHu/A+w4g43kaQQoYHik/ruaQBDL72HyCy1vuMw==} + /@types/react@18.3.31: + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} dependencies: '@types/prop-types': 15.7.5 - '@types/scheduler': 0.16.3 - csstype: 3.1.2 - - /@types/responselike@1.0.0: - resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} - dependencies: - '@types/node': 20.9.3 - dev: false - - /@types/scheduler@0.16.3: - resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} - - /@types/semver@6.2.3: - resolution: {integrity: sha512-KQf+QAMWKMrtBMsB8/24w53tEsxllMj6TuA80TT/5igJalLI/zm0L3oXRbIAl4Ohfc85gyHX/jhMwsVkmhLU4A==} - dev: false + csstype: 3.2.3 /@types/semver@7.5.0: resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} @@ -2185,14 +2703,14 @@ packages: resolution: {integrity: sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g==} dev: false - /@types/ws@8.5.9: - resolution: {integrity: sha512-jbdrY0a8lxfdTp/+r7Z4CkycbOFN8WX+IOchLJr3juT/xzbJ8URyTVSJ/hvNdadTgM1mnedb47n+Y31GsFnQlg==} + /@types/ws@8.18.1: + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} dependencies: - '@types/node': 20.9.3 + '@types/node': 20.19.43 dev: false - /@typescript-eslint/eslint-plugin@6.12.0(@typescript-eslint/parser@6.12.0)(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-XOpZ3IyJUIV1b15M7HVOpgQxPPF7lGXgsfcEIu3yDxFPaf/xZKt7s9QO/pbk7vpWQyVulpJbu4E5LwpZiQo4kA==} + /@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1)(typescript@5.9.3): + resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha @@ -2203,24 +2721,24 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.6.2 - '@typescript-eslint/parser': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - '@typescript-eslint/scope-manager': 6.12.0 - '@typescript-eslint/type-utils': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - '@typescript-eslint/utils': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - '@typescript-eslint/visitor-keys': 6.12.0 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/type-utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.3.4 - eslint: 8.54.0 + eslint: 8.57.1 graphemer: 1.4.0 ignore: 5.2.4 natural-compare: 1.4.0 semver: 7.5.4 - ts-api-utils: 1.0.1(typescript@5.3.2) - typescript: 5.3.2 + ts-api-utils: 1.0.1(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - /@typescript-eslint/parser@6.12.0(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-s8/jNFPKPNRmXEnNXfuo1gemBdVmpQsK1pcu+QIvuNJuhFzGrpD7WjOcvDc/+uEdfzSYpNu7U/+MmbScjoQ6vg==} + /@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3): + resolution: {integrity: sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -2229,25 +2747,25 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 6.12.0 - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/typescript-estree': 6.12.0(typescript@5.3.2) - '@typescript-eslint/visitor-keys': 6.12.0 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.3.4 - eslint: 8.54.0 - typescript: 5.3.2 + eslint: 8.57.1 + typescript: 5.9.3 transitivePeerDependencies: - supports-color - /@typescript-eslint/scope-manager@6.12.0: - resolution: {integrity: sha512-5gUvjg+XdSj8pcetdL9eXJzQNTl3RD7LgUiYTl8Aabdi8hFkaGSYnaS6BLc0BGNaDH+tVzVwmKtWvu0jLgWVbw==} + /@typescript-eslint/scope-manager@6.21.0: + resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/visitor-keys': 6.12.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 - /@typescript-eslint/type-utils@6.12.0(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-WWmRXxhm1X8Wlquj+MhsAG4dU/Blvf1xDgGaYCzfvStP2NwPQh6KBvCDbiOEvaE0filhranjIlK/2fSTVwtBng==} + /@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.9.3): + resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -2256,21 +2774,21 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.12.0(typescript@5.3.2) - '@typescript-eslint/utils': 6.12.0(eslint@8.54.0)(typescript@5.3.2) + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + '@typescript-eslint/utils': 6.21.0(eslint@8.57.1)(typescript@5.9.3) debug: 4.3.4 - eslint: 8.54.0 - ts-api-utils: 1.0.1(typescript@5.3.2) - typescript: 5.3.2 + eslint: 8.57.1 + ts-api-utils: 1.0.1(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - /@typescript-eslint/types@6.12.0: - resolution: {integrity: sha512-MA16p/+WxM5JG/F3RTpRIcuOghWO30//VEOvzubM8zuOOBYXsP+IfjoCXXiIfy2Ta8FRh9+IO9QLlaFQUU+10Q==} + /@typescript-eslint/types@6.21.0: + resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} engines: {node: ^16.0.0 || >=18.0.0} - /@typescript-eslint/typescript-estree@6.12.0(typescript@5.3.2): - resolution: {integrity: sha512-vw9E2P9+3UUWzhgjyyVczLWxZ3GuQNT7QpnIY3o5OMeLO/c8oHljGc8ZpryBMIyympiAAaKgw9e5Hl9dCWFOYw==} + /@typescript-eslint/typescript-estree@6.21.0(typescript@5.9.3): + resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' @@ -2278,62 +2796,119 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/visitor-keys': 6.12.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/visitor-keys': 6.21.0 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 - semver: 7.5.4 - ts-api-utils: 1.0.1(typescript@5.3.2) - typescript: 5.3.2 + minimatch: 9.0.3 + semver: 7.8.5 + ts-api-utils: 1.0.1(typescript@5.9.3) + typescript: 5.9.3 transitivePeerDependencies: - supports-color - /@typescript-eslint/utils@6.12.0(eslint@8.54.0)(typescript@5.3.2): - resolution: {integrity: sha512-LywPm8h3tGEbgfyjYnu3dauZ0U7R60m+miXgKcZS8c7QALO9uWJdvNoP+duKTk2XMWc7/Q3d/QiCuLN9X6SWyQ==} + /@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.9.3): + resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.54.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@types/json-schema': 7.0.12 '@types/semver': 7.5.0 - '@typescript-eslint/scope-manager': 6.12.0 - '@typescript-eslint/types': 6.12.0 - '@typescript-eslint/typescript-estree': 6.12.0(typescript@5.3.2) - eslint: 8.54.0 - semver: 7.5.4 + '@typescript-eslint/scope-manager': 6.21.0 + '@typescript-eslint/types': 6.21.0 + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.9.3) + eslint: 8.57.1 + semver: 7.8.5 transitivePeerDependencies: - supports-color - typescript - /@typescript-eslint/visitor-keys@6.12.0: - resolution: {integrity: sha512-rg3BizTZHF1k3ipn8gfrzDXXSFKyOEB5zxYXInQ6z0hUvmQlhaZQzK+YmHmNViMA9HzW5Q9+bPPt90bU6GQwyw==} + /@typescript-eslint/visitor-keys@6.21.0: + resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.12.0 - eslint-visitor-keys: 3.4.2 + '@typescript-eslint/types': 6.21.0 + eslint-visitor-keys: 3.4.3 /@ungap/structured-clone@1.2.0: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher + + /@vitest/expect@1.6.0: + resolution: {integrity: sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==} + dependencies: + '@vitest/spy': 1.6.0 + '@vitest/utils': 1.6.0 + chai: 4.5.0 + dev: true + + /@vitest/runner@1.6.0: + resolution: {integrity: sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==} + dependencies: + '@vitest/utils': 1.6.0 + p-limit: 5.0.0 + pathe: 1.1.2 + dev: true + + /@vitest/snapshot@1.6.0: + resolution: {integrity: sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==} + dependencies: + magic-string: 0.30.21 + pathe: 1.1.2 + pretty-format: 29.7.0 + dev: true + + /@vitest/spy@1.6.0: + resolution: {integrity: sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==} + dependencies: + tinyspy: 2.2.1 + dev: true - /@vladfrangu/async_event_emitter@2.2.2: - resolution: {integrity: sha512-HIzRG7sy88UZjBJamssEczH5q7t5+axva19UbZLO6u0ySbYPrwzWiXBcC0WuHyhKKoeCyneH+FvYzKQq/zTtkQ==} + /@vitest/utils@1.6.0: + resolution: {integrity: sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==} + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + dev: true + + /@vladfrangu/async_event_emitter@2.4.7: + resolution: {integrity: sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} dev: false - /acorn-jsx@5.3.2(acorn@8.10.0): + /acorn-jsx@5.3.2(acorn@8.18.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 dependencies: - acorn: 8.10.0 + acorn: 8.18.0 + + /acorn-walk@8.3.5: + resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} + engines: {node: '>=0.4.0'} + dependencies: + acorn: 8.18.0 + dev: true - /acorn@8.10.0: - resolution: {integrity: sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==} + /acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.3.4 + transitivePeerDependencies: + - supports-color + dev: false + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -2359,6 +2934,11 @@ packages: dependencies: color-convert: 2.0.1 + /ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + dev: true + /any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -2372,26 +2952,19 @@ packages: /arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - /argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - dependencies: - sprintf-js: 1.0.3 - dev: false - /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - /aria-hidden@1.2.3: - resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==} + /aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} dependencies: - tslib: 2.6.2 + tslib: 2.8.1 dev: false - /aria-query@5.3.0: - resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} - dependencies: - dequal: 2.0.3 + /aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} dev: false /array-buffer-byte-length@1.0.0: @@ -2401,51 +2974,55 @@ packages: is-array-buffer: 3.0.2 dev: false - /array-includes@3.1.6: - resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} + /array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 - is-string: 1.0.7 + call-bound: 1.0.4 + is-array-buffer: 3.0.5 dev: false - /array-includes@3.1.7: - resolution: {integrity: sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==} + /array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 - is-string: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 dev: false /array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} - /array.prototype.findlastindex@1.2.3: - resolution: {integrity: sha512-LzLoiOMAxvy+Gd3BAq3B7VeIgPdo+Q8hthvKtXybMvRV0jrXfJM/t8mw7nNlpEcVlVUnCnM2KSX4XU5HmpodOA==} + /array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 - get-intrinsic: 1.2.1 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 dev: false - /array.prototype.flat@1.3.1: - resolution: {integrity: sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==} + /array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 dev: false /array.prototype.flat@1.3.2: @@ -2453,19 +3030,19 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 + define-properties: 1.2.1 es-abstract: 1.22.1 es-shim-unscopables: 1.0.0 dev: false - /array.prototype.flatmap@1.3.1: - resolution: {integrity: sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==} + /array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 dev: false /array.prototype.flatmap@1.3.2: @@ -2473,19 +3050,30 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 + define-properties: 1.2.1 es-abstract: 1.22.1 es-shim-unscopables: 1.0.0 dev: false - /array.prototype.tosorted@1.1.1: - resolution: {integrity: sha512-pZYPXPRl2PqWcsUs6LOMn+1f1532nEoPTYowBtqLwAW+W8vSVhkIGnmOX1t/UQjD6YGI0vcD2B1U7ZFGQH9jnQ==} + /array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - es-shim-unscopables: 1.0.0 - get-intrinsic: 1.2.1 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + dev: false + + /array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 dev: false /arraybuffer.prototype.slice@1.0.1: @@ -2495,11 +3083,28 @@ packages: array-buffer-byte-length: 1.0.0 call-bind: 1.0.2 define-properties: 1.2.0 - get-intrinsic: 1.2.1 + get-intrinsic: 1.3.0 is-array-buffer: 3.0.2 is-shared-array-buffer: 1.0.2 dev: false + /arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + dev: false + + /assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + dev: true + /ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} dev: false @@ -2508,29 +3113,27 @@ packages: resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} dev: false - /asynciterator.prototype@1.0.0: - resolution: {integrity: sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==} - dependencies: - has-symbols: 1.0.3 - dev: false - /asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: false - /autoprefixer@10.4.16(postcss@8.4.31): - resolution: {integrity: sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==} + /atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + dev: false + + /autoprefixer@10.5.4(postcss@8.5.26): + resolution: {integrity: sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: postcss: ^8.1.0 dependencies: - browserslist: 4.22.1 - caniuse-lite: 1.0.30001563 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.0.0 - postcss: 8.4.31 + browserslist: 4.28.8 + caniuse-lite: 1.0.30001810 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.26 postcss-value-parser: 4.2.0 dev: true @@ -2539,33 +3142,41 @@ packages: engines: {node: '>= 0.4'} dev: false - /axe-core@4.7.0: - resolution: {integrity: sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==} + /available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + dependencies: + possible-typed-array-names: 1.1.0 + dev: false + + /axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} engines: {node: '>=4'} dev: false /axios@0.24.0: resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==} dependencies: - follow-redirects: 1.15.2 + follow-redirects: 1.16.0 transitivePeerDependencies: - debug dev: false - /axios@1.6.2: - resolution: {integrity: sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==} + /axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} dependencies: - follow-redirects: 1.15.2 - form-data: 4.0.0 - proxy-from-env: 1.1.0 + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 transitivePeerDependencies: - debug + - supports-color dev: false - /axobject-query@3.2.1: - resolution: {integrity: sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==} - dependencies: - dequal: 2.0.3 + /axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} dev: false /balanced-match@1.0.2: @@ -2575,6 +3186,12 @@ packages: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: false + /baseline-browser-mapping@2.11.20: + resolution: {integrity: sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==} + engines: {node: '>=6.0.0'} + hasBin: true + dev: true + /binary-extensions@2.2.0: resolution: {integrity: sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==} engines: {node: '>=8'} @@ -2589,38 +3206,29 @@ packages: balanced-match: 1.0.2 concat-map: 0.0.1 - /braces@3.0.2: - resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} - engines: {node: '>=8'} + /brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} dependencies: - fill-range: 7.0.1 + balanced-match: 1.0.2 - /browserslist@4.21.9: - resolution: {integrity: sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true + /braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} dependencies: - caniuse-lite: 1.0.30001517 - electron-to-chromium: 1.4.468 - node-releases: 2.0.13 - update-browserslist-db: 1.0.11(browserslist@4.21.9) - dev: false + fill-range: 7.1.1 - /browserslist@4.22.1: - resolution: {integrity: sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==} + /browserslist@4.28.8: + resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001563 - electron-to-chromium: 1.4.589 - node-releases: 2.0.13 - update-browserslist-db: 1.0.13(browserslist@4.22.1) + baseline-browser-mapping: 2.11.20 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.416 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.8) dev: true - /builtins@1.0.3: - resolution: {integrity: sha512-uYBjakWipfaO/bXI7E8rq6kpwHRZK5cNYrUv2OzZSI/FvmdMyXJ2tG9dKcjEC5YHmHpUAwsargWIZNWdxb/bnQ==} - dev: false - /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -2628,24 +3236,42 @@ packages: streamsearch: 1.1.0 dev: false - /cacheable-request@6.1.0: - resolution: {integrity: sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==} + /cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + dev: true + + /call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} dependencies: - clone-response: 1.0.3 - get-stream: 5.2.0 - http-cache-semantics: 4.1.1 - keyv: 3.1.0 - lowercase-keys: 2.0.0 - normalize-url: 4.5.1 - responselike: 1.0.2 + es-errors: 1.3.0 + function-bind: 1.1.2 dev: false /call-bind@1.0.2: resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} dependencies: - function-bind: 1.1.1 - get-intrinsic: 1.2.1 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + dev: false + + /call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + dev: false + + /call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 dev: false /callsites@3.1.0: @@ -2656,12 +3282,20 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - /caniuse-lite@1.0.30001517: - resolution: {integrity: sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==} - dev: false + /caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} - /caniuse-lite@1.0.30001563: - resolution: {integrity: sha512-na2WUmOxnwIZtwnFI2CZ/3er0wdNzU7hN+cPYz/z2ajHThnkWjNBOpEPP4n+4r2WPM847JaMotaJE3bnfzjyKw==} + /chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 dev: true /chalk@2.4.2: @@ -2680,6 +3314,12 @@ packages: ansi-styles: 4.3.0 supports-color: 7.2.0 + /check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + dependencies: + get-func-name: 2.0.2 + dev: true + /cheerio-select@2.1.0: resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} dependencies: @@ -2709,40 +3349,48 @@ packages: engines: {node: '>= 8.10.0'} dependencies: anymatch: 3.1.3 - braces: 3.0.2 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + dev: false + + /chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 glob-parent: 5.1.2 is-binary-path: 2.1.0 is-glob: 4.0.3 normalize-path: 3.0.0 readdirp: 3.6.0 optionalDependencies: - fsevents: 2.3.2 + fsevents: 2.3.3 - /class-variance-authority@0.7.0: - resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} + /class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} dependencies: - clsx: 2.0.0 + clsx: 2.1.1 dev: false /client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} dev: false - /clone-response@1.0.3: - resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} - dependencies: - mimic-response: 1.0.1 - dev: false - - /clsx@2.0.0: - resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} + /clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} dev: false /cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} - dev: false /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -2756,6 +3404,13 @@ packages: dependencies: color-name: 1.1.4 + /color-convert@3.1.3: + resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} + engines: {node: '>=14.6'} + dependencies: + color-name: 2.1.1 + dev: false + /color-name@1.1.3: resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} dev: false @@ -2763,29 +3418,45 @@ packages: /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + /color-name@2.1.1: + resolution: {integrity: sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==} + engines: {node: '>=12.20'} + dev: false + /color-string@1.9.1: resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} dependencies: color-name: 1.1.4 - simple-swizzle: 0.2.2 + simple-swizzle: 0.2.4 dev: false + optional: true - /color@3.2.1: - resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==} + /color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} dependencies: - color-convert: 1.9.3 - color-string: 1.9.1 + color-name: 2.1.1 dev: false - /colorette@2.0.20: - resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + /color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 dev: false + optional: true - /colorspace@1.1.4: - resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==} + /color@5.0.3: + resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} + engines: {node: '>=18'} dependencies: - color: 3.2.1 - text-hex: 1.0.0 + color-convert: 3.1.3 + color-string: 2.1.4 + dev: false + + /colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} dev: false /combined-stream@1.0.8: @@ -2802,14 +3473,16 @@ packages: /concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - /convert-source-map@1.9.0: - resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} - dev: false + /confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + dev: true - /cookie@0.5.0: - resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} - engines: {node: '>= 0.6'} - dev: false + /config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + dev: true /copy-anything@3.0.5: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} @@ -2818,14 +3491,6 @@ packages: is-what: 4.1.15 dev: false - /cross-spawn@5.1.0: - resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} - dependencies: - lru-cache: 4.1.5 - shebang-command: 1.2.0 - which: 1.3.1 - dev: false - /cross-spawn@6.0.5: resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==} engines: {node: '>=4.8'} @@ -2837,8 +3502,8 @@ packages: which: 1.3.1 dev: false - /cross-spawn@7.0.3: - resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + /cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} dependencies: path-key: 3.1.1 @@ -2865,8 +3530,8 @@ packages: engines: {node: '>=4'} hasBin: true - /csstype@3.1.2: - resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} + /csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} /damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -2877,6 +3542,37 @@ packages: engines: {node: '>= 12'} dev: false + /data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dev: false + + /data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dev: false + + /data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + dev: false + + /dateformat@4.6.3: + resolution: {integrity: sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==} + dev: false + /debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2899,34 +3595,39 @@ packages: dependencies: ms: 2.1.2 - /decompress-response@3.3.0: - resolution: {integrity: sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==} - engines: {node: '>=4'} + /deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} dependencies: - mimic-response: 1.0.1 - dev: false + type-detect: 4.1.0 + dev: true /deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} - dev: false + dev: true /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - /defer-to-connect@1.1.3: - resolution: {integrity: sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==} - dev: false - /define-data-property@1.1.1: resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==} engines: {node: '>= 0.4'} dependencies: - get-intrinsic: 1.2.1 - gopd: 1.0.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 has-property-descriptors: 1.0.0 dev: false + /define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + dev: false + /define-properties@1.2.0: resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} engines: {node: '>= 0.4'} @@ -2952,17 +3653,17 @@ packages: /denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} - dev: false - /dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - dev: false + /detect-indent@7.0.2: + resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} + engines: {node: '>=12.20'} + dev: true - /detect-indent@6.1.0: - resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + /detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} dev: false + optional: true /detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} @@ -2971,38 +3672,50 @@ packages: /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + /diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + /dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} dependencies: path-type: 4.0.0 + /discord-api-types@0.37.119: + resolution: {integrity: sha512-WasbGFXEB+VQWXlo6IpW3oUv73Yuau1Ig4AZF/m13tXcTKnMpc/mHjpztIlz4+BM9FG9BHQkEXiPto3bKduQUg==} + dev: false + + /discord-api-types@0.37.120: + resolution: {integrity: sha512-7xpNK0EiWjjDFp2nAhHXezE4OUWm7s1zhc/UXXN6hnFFU8dfoPHgV0Hx0RPiCa3ILRpdeh152icc68DGCyXYIw==} + dev: false + /discord-api-types@0.37.61: resolution: {integrity: sha512-o/dXNFfhBpYHpQFdT6FWzeO7pKc838QeeZ9d91CfVAtpr5XLK4B/zYxQbYgPdoMiTDvJfzcsLW5naXgmHGDNXw==} dev: false - /discord-api-types@0.37.64: - resolution: {integrity: sha512-9aS+QuoNj+4e9d5uDKfds1DCpQLYn/mHx+M8OFHZ/ZZJVadZJEo275uBOaSsw5KGYGsZ4hxMzlOkIxnWirgqKA==} + /discord-api-types@0.38.54: + resolution: {integrity: sha512-3704EKdPtVl0Mozoe6uBJQ8GNmUkH8c81nfXkdSBx+Um8GN3FI/003uTY0Pg0A4KjL8g2Q+4v5cHR247kv6vwA==} dev: false - /discord.js@14.14.1: - resolution: {integrity: sha512-/hUVzkIerxKHyRKopJy5xejp4MYKDPTszAnpYxzVVv4qJYf+Tkt+jnT2N29PIPschicaEEpXwF2ARrTYHYwQ5w==} - engines: {node: '>=16.11.0'} + /discord.js@14.27.0: + resolution: {integrity: sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==} + engines: {node: '>=18'} dependencies: - '@discordjs/builders': 1.7.0 + '@discordjs/builders': 1.14.1 '@discordjs/collection': 1.5.3 - '@discordjs/formatters': 0.3.3 - '@discordjs/rest': 2.2.0 - '@discordjs/util': 1.0.2 - '@discordjs/ws': 1.0.2 - '@sapphire/snowflake': 3.5.1 - '@types/ws': 8.5.9 - discord-api-types: 0.37.61 + '@discordjs/formatters': 0.6.2 + '@discordjs/rest': 2.6.3 + '@discordjs/util': 1.2.0 + '@discordjs/ws': 1.2.3 + '@sapphire/snowflake': 3.5.5 + discord-api-types: 0.38.54 fast-deep-equal: 3.1.3 lodash.snakecase: 4.1.1 - tslib: 2.6.2 - undici: 5.27.2 - ws: 8.14.2 + magic-bytes.js: 1.13.1 + tslib: 2.8.1 + undici: 6.28.0 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -3051,12 +3764,12 @@ packages: domhandler: 5.0.3 dev: false - /dotenv-cli@7.3.0: - resolution: {integrity: sha512-314CA4TyK34YEJ6ntBf80eUY+t1XaFLyem1k9P0sX1gn30qThZ5qZr/ZwE318gEnzyYP9yj9HJk6SqwE0upkfw==} + /dotenv-cli@7.4.4: + resolution: {integrity: sha512-XkBYCG0tPIes+YZr4SpfFv76SQrV/LeCE8CI7JSEMi3VR9MvTihCGTOtbIexD6i2mXF+6px7trb1imVCXSNMDw==} hasBin: true dependencies: - cross-spawn: 7.0.3 - dotenv: 16.3.1 + cross-spawn: 7.0.6 + dotenv: 16.6.1 dotenv-expand: 10.0.0 minimist: 1.2.8 dev: true @@ -3071,21 +3784,27 @@ packages: engines: {node: '>=12'} dev: false - /dotenv@16.3.1: - resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} + /dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} dev: true - /duplexer3@0.1.5: - resolution: {integrity: sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==} + /dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} dev: false - /electron-to-chromium@1.4.468: - resolution: {integrity: sha512-6M1qyhaJOt7rQtNti1lBA0GwclPH+oKCmsra/hkcWs5INLxfXXD/dtdnaKUYQu/pjOBP/8Osoe4mAcNvvzoFag==} + /dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 dev: false - /electron-to-chromium@1.4.589: - resolution: {integrity: sha512-zF6y5v/YfoFIgwf2dDfAqVlPPsyQeWNpEWXbAlDUS8Ax4Z2VoiiZpAPC0Jm9hXEkJm2vIZpwB6rc4KnLTQffbQ==} + /electron-to-chromium@1.5.416: + resolution: {integrity: sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA==} dev: true /emoji-regex@9.2.2: @@ -3096,8 +3815,8 @@ packages: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} dev: false - /end-of-stream@1.4.4: - resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + /end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} dependencies: once: 1.4.0 dev: false @@ -3113,6 +3832,16 @@ packages: is-arrayish: 0.2.1 dev: false + /es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + dev: false + /es-abstract@1.22.1: resolution: {integrity: sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==} engines: {node: '>= 0.4'} @@ -3121,17 +3850,17 @@ packages: arraybuffer.prototype.slice: 1.0.1 available-typed-arrays: 1.0.5 call-bind: 1.0.2 - es-set-tostringtag: 2.0.1 + es-set-tostringtag: 2.1.0 es-to-primitive: 1.2.1 function.prototype.name: 1.1.5 - get-intrinsic: 1.2.1 + get-intrinsic: 1.3.0 get-symbol-description: 1.0.0 globalthis: 1.0.3 - gopd: 1.0.1 + gopd: 1.2.0 has: 1.0.3 has-property-descriptors: 1.0.0 has-proto: 1.0.1 - has-symbols: 1.0.3 + has-symbols: 1.1.0 internal-slot: 1.0.5 is-array-buffer: 3.0.2 is-callable: 1.2.7 @@ -3158,32 +3887,113 @@ packages: which-typed-array: 1.1.11 dev: false - /es-iterator-helpers@1.0.15: - resolution: {integrity: sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==} + /es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} dependencies: - asynciterator.prototype: 1.0.0 - call-bind: 1.0.2 + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.2 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.22 + dev: false + + /es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + dev: false + + /es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + dev: false + + /es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 define-properties: 1.2.1 - es-abstract: 1.22.1 - es-set-tostringtag: 2.0.1 - function-bind: 1.1.1 - get-intrinsic: 1.2.1 - globalthis: 1.0.3 - has-property-descriptors: 1.0.0 - has-proto: 1.0.1 - has-symbols: 1.0.3 - internal-slot: 1.0.5 - iterator.prototype: 1.1.2 - safe-array-concat: 1.0.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + dev: false + + /es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 dev: false - /es-set-tostringtag@2.0.1: - resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==} + /es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} dependencies: - get-intrinsic: 1.2.1 - has: 1.0.3 - has-tostringtag: 1.0.0 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 dev: false /es-shim-unscopables@1.0.0: @@ -3192,6 +4002,13 @@ packages: has: 1.0.3 dev: false + /es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + dependencies: + hasown: 2.0.4 + dev: false + /es-to-primitive@1.2.1: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} @@ -3201,9 +4018,53 @@ packages: is-symbol: 1.0.4 dev: false - /escalade@3.1.1: - resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + /es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} + engines: {node: '>= 0.4'} + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + dev: false + + /esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + dev: true + + /escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + dev: true /escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} @@ -3214,36 +4075,36 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} - /eslint-config-prettier@9.0.0(eslint@8.54.0): - resolution: {integrity: sha512-IcJsTkJae2S35pRsRAwoCE+925rJJStOdkKnLVgtE+tEpqU0EVVM7OqrwxqgptKdX29NUwC82I5pXsGFIgSevw==} + /eslint-config-prettier@9.1.2(eslint@8.57.1): + resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==} hasBin: true peerDependencies: eslint: '>=7.0.0' dependencies: - eslint: 8.54.0 + eslint: 8.57.1 dev: false - /eslint-config-turbo@1.10.16(eslint@8.54.0): - resolution: {integrity: sha512-O3NQI72bQHV7FvSC6lWj66EGx8drJJjuT1kuInn6nbMLOHdMBhSUX/8uhTAlHRQdlxZk2j9HtgFCIzSc93w42g==} + /eslint-config-turbo@1.13.4(eslint@8.57.1): + resolution: {integrity: sha512-+we4eWdZlmlEn7LnhXHCIPX/wtujbHCS7XjQM/TN09BHNEl2fZ8id4rHfdfUKIYTSKyy8U/nNyJ0DNoZj5Q8bw==} peerDependencies: eslint: '>6.6.0' dependencies: - eslint: 8.54.0 - eslint-plugin-turbo: 1.10.16(eslint@8.54.0) + eslint: 8.57.1 + eslint-plugin-turbo: 1.13.4(eslint@8.57.1) dev: false /eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} dependencies: debug: 3.2.7 - is-core-module: 2.13.1 + is-core-module: 2.16.2 resolve: 1.22.8 transitivePeerDependencies: - supports-color dev: false - /eslint-module-utils@2.8.0(@typescript-eslint/parser@6.12.0)(eslint-import-resolver-node@0.3.9)(eslint@8.54.0): - resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} + /eslint-module-utils@2.14.0(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -3263,115 +4124,118 @@ packages: eslint-import-resolver-webpack: optional: true dependencies: - '@typescript-eslint/parser': 6.12.0(eslint@8.54.0)(typescript@5.3.2) + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) debug: 3.2.7 - eslint: 8.54.0 + eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 transitivePeerDependencies: - supports-color dev: false - /eslint-plugin-import@2.29.0(@typescript-eslint/parser@6.12.0)(eslint@8.54.0): - resolution: {integrity: sha512-QPOO5NO6Odv5lpoTkddtutccQjysJuFxoPS7fAHO+9m9udNHvTCPSAMW9zGAYj8lAIdr40I8yPCdUYrncXtrwg==} + /eslint-plugin-import@2.32.0(@typescript-eslint/parser@6.21.0)(eslint@8.57.1): + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 peerDependenciesMeta: '@typescript-eslint/parser': optional: true dependencies: - '@typescript-eslint/parser': 6.12.0(eslint@8.54.0)(typescript@5.3.2) - array-includes: 3.1.7 - array.prototype.findlastindex: 1.2.3 - array.prototype.flat: 1.3.2 - array.prototype.flatmap: 1.3.2 + '@rtsao/scc': 1.1.0 + '@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3) + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 debug: 3.2.7 doctrine: 2.1.0 - eslint: 8.54.0 + eslint: 8.57.1 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.0(@typescript-eslint/parser@6.12.0)(eslint-import-resolver-node@0.3.9)(eslint@8.54.0) - hasown: 2.0.0 - is-core-module: 2.13.1 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@6.21.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) + hasown: 2.0.4 + is-core-module: 2.16.2 is-glob: 4.0.3 minimatch: 3.1.2 - object.fromentries: 2.0.7 - object.groupby: 1.0.1 - object.values: 1.1.7 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 semver: 6.3.1 - tsconfig-paths: 3.14.2 + string.prototype.trimend: 1.0.10 + tsconfig-paths: 3.15.0 transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color dev: false - /eslint-plugin-jsx-a11y@6.8.0(eslint@8.54.0): - resolution: {integrity: sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==} + /eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1): + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} engines: {node: '>=4.0'} peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 dependencies: - '@babel/runtime': 7.23.4 - aria-query: 5.3.0 - array-includes: 3.1.7 + aria-query: 5.3.2 + array-includes: 3.1.9 array.prototype.flatmap: 1.3.2 ast-types-flow: 0.0.8 - axe-core: 4.7.0 - axobject-query: 3.2.1 + axe-core: 4.13.0 + axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - es-iterator-helpers: 1.0.15 - eslint: 8.54.0 - hasown: 2.0.0 + eslint: 8.57.1 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.2 - object.entries: 1.1.7 - object.fromentries: 2.0.7 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 dev: false - /eslint-plugin-react-hooks@4.6.0(eslint@8.54.0): - resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} + /eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} engines: {node: '>=10'} peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 dependencies: - eslint: 8.54.0 + eslint: 8.57.1 dev: false - /eslint-plugin-react@7.33.2(eslint@8.54.0): - resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==} + /eslint-plugin-react@7.37.5(eslint@8.57.1): + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} engines: {node: '>=4'} peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 dependencies: - array-includes: 3.1.6 - array.prototype.flatmap: 1.3.1 - array.prototype.tosorted: 1.1.1 + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 - es-iterator-helpers: 1.0.15 - eslint: 8.54.0 + es-iterator-helpers: 1.4.0 + eslint: 8.57.1 estraverse: 5.3.0 - jsx-ast-utils: 3.3.4 + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 minimatch: 3.1.2 - object.entries: 1.1.6 - object.fromentries: 2.0.6 - object.hasown: 1.1.2 - object.values: 1.1.6 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 prop-types: 15.8.1 - resolve: 2.0.0-next.4 + resolve: 2.0.0-next.7 semver: 6.3.1 - string.prototype.matchall: 4.0.8 + string.prototype.matchall: 4.1.0 + string.prototype.repeat: 1.0.0 dev: false - /eslint-plugin-turbo@1.10.16(eslint@8.54.0): - resolution: {integrity: sha512-ZjrR88MTN64PNGufSEcM0tf+V1xFYVbeiMeuIqr0aiABGomxFLo4DBkQ7WI4WzkZtWQSIA2sP+yxqSboEfL9MQ==} + /eslint-plugin-turbo@1.13.4(eslint@8.57.1): + resolution: {integrity: sha512-82GfMzrewI/DJB92Bbch239GWbGx4j1zvjk1lqb06lxIlMPnVwUHVwPbAnLfyLG3JuhLv9whxGkO/q1CL18JTg==} peerDependencies: eslint: '>6.6.0' dependencies: dotenv: 16.0.3 - eslint: 8.54.0 + eslint: 8.57.1 dev: false /eslint-scope@7.2.2: @@ -3381,30 +4245,27 @@ packages: esrecurse: 4.3.0 estraverse: 5.3.0 - /eslint-visitor-keys@3.4.2: - resolution: {integrity: sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - /eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - /eslint@8.54.0: - resolution: {integrity: sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==} + /eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.54.0) + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) '@eslint-community/regexpp': 4.6.2 - '@eslint/eslintrc': 2.1.3 - '@eslint/js': 8.54.0 - '@humanwhocodes/config-array': 0.11.13 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.2.0 ajv: 6.12.6 chalk: 4.1.2 - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 debug: 4.3.4 doctrine: 3.0.0 escape-string-regexp: 4.0.0 @@ -3439,16 +4300,10 @@ packages: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: - acorn: 8.10.0 - acorn-jsx: 5.3.2(acorn@8.10.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 3.4.3 - /esprima@4.0.1: - resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} - engines: {node: '>=4'} - hasBin: true - dev: false - /esquery@1.5.0: resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} engines: {node: '>=0.10'} @@ -3465,10 +4320,35 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.1 + dev: true + /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + /execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + dependencies: + cross-spawn: 7.0.6 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + dev: true + + /fast-copy@4.1.1: + resolution: {integrity: sha512-A4QTJmuiztpGtr6AMeJts9R4hbj2ZBUwtOaKrG6rw2y7t6+IaJKjz5M3XDs8BUznxDH43FVc6A0y/gWlMl4UtA==} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3480,7 +4360,18 @@ packages: '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.5 + micromatch: 4.0.8 + dev: false + + /fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 /fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} @@ -3488,15 +4379,46 @@ packages: /fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + /fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + dev: false + /fastq@1.15.0: resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} dependencies: reusify: 1.0.4 + /fdir@6.5.0(picomatch@4.0.7): + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + dependencies: + picomatch: 4.0.7 + /fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} dev: false + /fengari-interop@0.1.4(fengari@0.1.5): + resolution: {integrity: sha512-4/CW/3PJUo3ebD4ACgE1g/3NGEYSq7OQAyETyypsAl/WeySDBbxExikkayNkZzbpgyC9GyJp8v1DU2VOXxNq7Q==} + peerDependencies: + fengari: ^0.1.0 + dependencies: + fengari: 0.1.5 + dev: false + + /fengari@0.1.5: + resolution: {integrity: sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==} + dependencies: + readline-sync: 1.4.10 + sprintf-js: 1.1.3 + tmp: 0.2.7 + dev: false + /fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -3517,20 +4439,12 @@ packages: moment: 2.29.4 dev: false - /fill-range@7.0.1: - resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + /fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} dependencies: to-regex-range: 5.0.1 - /find-up@4.1.0: - resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} - engines: {node: '>=8'} - dependencies: - locate-path: 5.0.0 - path-exists: 4.0.0 - dev: false - /find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -3552,8 +4466,8 @@ packages: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} dev: false - /follow-redirects@1.15.2: - resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==} + /follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} peerDependencies: debug: '*' @@ -3568,12 +4482,21 @@ packages: is-callable: 1.2.7 dev: false - /form-data@4.0.0: - resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + /for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + dependencies: + is-callable: 1.2.7 + dev: false + + /form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 mime-types: 2.1.35 dev: false @@ -3584,35 +4507,22 @@ packages: fetch-blob: 3.2.0 dev: false - /fraction.js@4.3.7: - resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + /fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} dev: true - /fs-extra@8.1.0: - resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} - engines: {node: '>=6 <7 || >=8'} - dependencies: - graceful-fs: 4.2.11 - jsonfile: 4.0.0 - universalify: 0.1.2 - dev: false - /fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - /fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] requiresBuild: true optional: true - /function-bind@1.1.1: - resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} - /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - dev: false /function.prototype.name@1.1.5: resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} @@ -3624,6 +4534,21 @@ packages: functions-have-names: 1.2.3 dev: false + /function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + dev: false + /functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} dev: false @@ -3637,18 +4562,24 @@ packages: - debug dev: false - /gensync@1.0.0-beta.2: - resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} - engines: {node: '>=6.9.0'} - dev: false + /get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + dev: true - /get-intrinsic@1.2.1: - resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} + /get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} dependencies: - function-bind: 1.1.1 - has: 1.0.3 - has-proto: 1.0.1 - has-symbols: 1.0.3 + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 dev: false /get-nonce@1.0.1: @@ -3656,26 +4587,34 @@ packages: engines: {node: '>=6'} dev: false - /get-stream@4.1.0: - resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} - engines: {node: '>=6'} + /get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} dependencies: - pump: 3.0.0 + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 dev: false - /get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} - engines: {node: '>=8'} - dependencies: - pump: 3.0.0 - dev: false + /get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + dev: true /get-symbol-description@1.0.0: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.1 + get-intrinsic: 1.3.0 + dev: false + + /get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 dev: false /glob-parent@5.1.2: @@ -3690,33 +4629,9 @@ packages: dependencies: is-glob: 4.0.3 - /glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - dev: false - - /glob@7.1.6: - resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - - /glob@7.1.7: - resolution: {integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==} - dependencies: - fs.realpath: 1.0.0 - inflight: 1.0.6 - inherits: 2.0.4 - minimatch: 3.1.2 - once: 1.4.0 - path-is-absolute: 1.0.1 - dev: false - /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -3725,11 +4640,6 @@ packages: once: 1.4.0 path-is-absolute: 1.0.1 - /globals@11.12.0: - resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} - engines: {node: '>=4'} - dev: false - /globals@13.20.0: resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} engines: {node: '>=8'} @@ -3743,46 +4653,38 @@ packages: define-properties: 1.2.0 dev: false + /globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + dev: false + /globby@11.1.0: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} dependencies: array-union: 2.1.0 dir-glob: 3.0.1 - fast-glob: 3.3.1 + fast-glob: 3.3.3 ignore: 5.2.4 merge2: 1.4.1 slash: 3.0.0 - /google-translate-api-x@10.6.7: - resolution: {integrity: sha512-xw20Kjv5u84Q3FwKTk4CU1PZrYoOsRcKq0z7J3a98aIcscOCZnW3T43Rb/SOl/JPUj/QYyjJiRW9G+MQhxUnAw==} - engines: {node: '>=14.0.0'} + /google-translate-api-x@10.7.3: + resolution: {integrity: sha512-UCLhGMyzUiQQJuUjw6KM4scl4WJjMq5kYbq3eqLHB0KB96XBQ+VV7L/NUpJ9eMfVxXswXxA6xWhAX1h1OdDLZg==} + engines: {node: '>=21.0.0'} dev: false - /gopd@1.0.1: - resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} - dependencies: - get-intrinsic: 1.2.1 + /gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} dev: false - /got@9.6.0: - resolution: {integrity: sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==} - engines: {node: '>=8.6'} - dependencies: - '@sindresorhus/is': 0.14.0 - '@szmarczak/http-timer': 1.1.2 - '@types/keyv': 3.1.4 - '@types/responselike': 1.0.0 - cacheable-request: 6.1.0 - decompress-response: 3.3.0 - duplexer3: 0.1.5 - get-stream: 4.1.0 - lowercase-keys: 1.0.1 - mimic-response: 1.0.1 - p-cancelable: 1.1.0 - to-readable-stream: 1.0.0 - url-parse-lax: 3.0.0 - dev: false + /graceful-fs@4.2.10: + resolution: {integrity: sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==} + dev: true /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -3807,7 +4709,13 @@ packages: /has-property-descriptors@1.0.0: resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} dependencies: - get-intrinsic: 1.2.1 + get-intrinsic: 1.3.0 + dev: false + + /has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + dependencies: + es-define-property: 1.0.1 dev: false /has-proto@1.0.1: @@ -3815,29 +4723,40 @@ packages: engines: {node: '>= 0.4'} dev: false - /has-symbols@1.0.3: - resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + /has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + dependencies: + dunder-proto: 1.0.1 + dev: false + + /has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} dev: false - /has-tostringtag@1.0.0: - resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} + /has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} dependencies: - has-symbols: 1.0.3 + has-symbols: 1.1.0 dev: false /has@1.0.3: resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} engines: {node: '>= 0.4.0'} dependencies: - function-bind: 1.1.1 + function-bind: 1.1.2 + dev: false - /hasown@2.0.0: - resolution: {integrity: sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==} + /hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} dependencies: function-bind: 1.1.2 + + /help-me@5.0.0: + resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} dev: false /hosted-git-info@2.8.9: @@ -3853,10 +4772,21 @@ packages: entities: 4.5.0 dev: false - /http-cache-semantics@4.1.1: - resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4 + transitivePeerDependencies: + - supports-color dev: false + /human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + dev: true + /ignore@5.2.4: resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} engines: {node: '>= 4'} @@ -3874,6 +4804,7 @@ packages: /inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. dependencies: once: 1.4.0 wrappy: 1.0.2 @@ -3883,25 +4814,60 @@ packages: /ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - dev: false + dev: true /internal-slot@1.0.5: resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} engines: {node: '>= 0.4'} dependencies: - get-intrinsic: 1.2.1 + get-intrinsic: 1.3.0 has: 1.0.3 side-channel: 1.0.4 dev: false - /invariant@2.2.4: - resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + /internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} dependencies: - loose-envify: 1.4.0 + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + dev: false + + /ioredis-mock@8.13.1(@types/ioredis-mock@8.2.5)(ioredis@5.6.1): + resolution: {integrity: sha512-Wsi50AU+cMiI32nAgfwpUaJVBtb4iQdVsOHl9M6R3tePCO/8vGsToCVIG82XWAxN4Se55TZoOzVseu+QngFLyw==} + engines: {node: '>=12.22'} + peerDependencies: + '@types/ioredis-mock': ^8 + ioredis: ^5 + dependencies: + '@ioredis/as-callback': 3.0.0 + '@ioredis/commands': 1.11.0 + '@types/ioredis-mock': 8.2.5 + fengari: 0.1.5 + fengari-interop: 0.1.4(fengari@0.1.5) + ioredis: 5.6.1 + semver: 7.8.5 + dev: false + + /ioredis-mock@8.9.0(@types/ioredis-mock@8.2.5)(ioredis@5.6.1): + resolution: {integrity: sha512-yIglcCkI1lvhwJVoMsR51fotZVsPsSk07ecTCgRTRlicG0Vq3lke6aAaHklyjmRNRsdYAgswqC2A0bPtQK4LSw==} + engines: {node: '>=12.22'} + peerDependencies: + '@types/ioredis-mock': ^8 + ioredis: ^5 + dependencies: + '@ioredis/as-callback': 3.0.0 + '@ioredis/commands': 1.2.0 + '@types/ioredis-mock': 8.2.5 + fengari: 0.1.5 + fengari-interop: 0.1.4(fengari@0.1.5) + ioredis: 5.6.1 + semver: 7.8.5 dev: false - /ioredis@5.3.2: - resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} + /ioredis@5.6.1: + resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==} engines: {node: '>=12.22.0'} dependencies: '@ioredis/commands': 1.2.0 @@ -3915,29 +4881,38 @@ packages: standard-as-callback: 2.1.0 transitivePeerDependencies: - supports-color - dev: false /is-array-buffer@3.0.2: resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.1 + get-intrinsic: 1.3.0 is-typed-array: 1.1.12 dev: false + /is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + dev: false + /is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} dev: false - /is-arrayish@0.3.2: - resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + /is-arrayish@0.3.4: + resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==} dev: false + optional: true /is-async-function@2.0.0: resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: false /is-bigint@1.0.4: @@ -3946,6 +4921,13 @@ packages: has-bigints: 1.0.2 dev: false + /is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + dependencies: + has-bigints: 1.0.2 + dev: false + /is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} @@ -3957,7 +4939,15 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 + dev: false + + /is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 dev: false /is-callable@1.2.7: @@ -3965,39 +4955,65 @@ packages: engines: {node: '>= 0.4'} dev: false - /is-core-module@2.12.1: - resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} - dependencies: - has: 1.0.3 - /is-core-module@2.13.1: resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} dependencies: - hasown: 2.0.0 + hasown: 2.0.4 + + /is-core-module@2.16.2: + resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} + engines: {node: '>= 0.4'} + dependencies: + hasown: 2.0.4 + dev: false + + /is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 dev: false /is-date-object@1.0.5: resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 + dev: false + + /is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + dev: false + + /is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 dev: false /is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - /is-finalizationregistry@1.0.2: - resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + /is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 + call-bound: 1.0.4 dev: false /is-generator-function@1.0.10: resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: false /is-glob@4.0.3: @@ -4006,8 +5022,9 @@ packages: dependencies: is-extglob: 2.1.1 - /is-map@2.0.2: - resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} + /is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} dev: false /is-negative-zero@2.0.2: @@ -4015,11 +5032,24 @@ packages: engines: {node: '>= 0.4'} dev: false + /is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + dev: false + /is-number-object@1.0.7: resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 + dev: false + + /is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 dev: false /is-number@7.0.0: @@ -4035,11 +5065,22 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 dev: false - /is-set@2.0.2: - resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} + /is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + dev: false + + /is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} dev: false /is-shared-array-buffer@1.0.2: @@ -4048,23 +5089,52 @@ packages: call-bind: 1.0.2 dev: false + /is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + dev: false + /is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} dev: false + /is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + /is-string@1.0.7: resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} engines: {node: '>= 0.4'} dependencies: - has-tostringtag: 1.0.0 + has-tostringtag: 1.0.2 + dev: false + + /is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 dev: false /is-symbol@1.0.4: resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} engines: {node: '>= 0.4'} dependencies: - has-symbols: 1.0.3 + has-symbols: 1.1.0 + dev: false + + /is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 dev: false /is-typed-array@1.1.12: @@ -4074,8 +5144,16 @@ packages: which-typed-array: 1.1.11 dev: false - /is-weakmap@2.0.1: - resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} + /is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + dependencies: + which-typed-array: 1.1.22 + dev: false + + /is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} dev: false /is-weakref@1.0.2: @@ -4084,11 +5162,19 @@ packages: call-bind: 1.0.2 dev: false - /is-weakset@2.0.2: - resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} + /is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.1 + call-bound: 1.0.4 + dev: false + + /is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 dev: false /is-what@4.1.15: @@ -4103,48 +5189,46 @@ packages: /isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - /iso-639-1@3.1.0: - resolution: {integrity: sha512-rWcHp9dcNbxa5C8jA/cxFlWNFNwy5Vup0KcFvgA8sPQs9ZeJHj/Eq0Y8Yz2eL8XlWYpxw4iwh9FfTeVxyqdRMw==} + /iso-639-1@3.1.6: + resolution: {integrity: sha512-ZFar/L4ngX7wZh2QX+Fiftmuf0igWJsrJtfizrovWifF1gAWkfmRa5Z1m0LQZbm0hKCHRDYhLRSLFrSqNe4EJA==} engines: {node: '>=6.0'} dev: false - /iterator.prototype@1.1.2: - resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + /iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} dependencies: - define-properties: 1.2.1 - get-intrinsic: 1.2.1 - has-symbols: 1.0.3 - reflect.getprototypeof: 1.0.4 - set-function-name: 2.0.1 + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 dev: false - /jiti@1.19.1: - resolution: {integrity: sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg==} + /jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true /jju@1.4.0: resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} - dev: false + dev: true - /jose@4.15.4: - resolution: {integrity: sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==} + /jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} dev: false - /jose@5.1.1: - resolution: {integrity: sha512-bfB+lNxowY49LfrBO0ITUn93JbUhxUN8I11K6oI5hJu/G6PO6fEUddVLjqdD0cQ9SXIHWXuWh7eJYwZF7Z0N/g==} + /joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} dev: false /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - dev: false - /js-yaml@3.14.1: - resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} - hasBin: true - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - dev: false + /js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + dev: true /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} @@ -4152,15 +5236,18 @@ packages: dependencies: argparse: 2.0.1 - /jsesc@2.5.2: - resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} - engines: {node: '>=4'} + /js-yaml@5.4.2: + resolution: {integrity: sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==} hasBin: true + dependencies: + argparse: 2.0.1 dev: false - /json-buffer@3.0.0: - resolution: {integrity: sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==} - dev: false + /jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + dev: true /json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} @@ -4179,48 +5266,25 @@ packages: minimist: 1.2.8 dev: false - /json5@2.2.3: - resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} - engines: {node: '>=6'} - hasBin: true - dev: false - - /jsonfile@4.0.0: - resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - optionalDependencies: - graceful-fs: 4.2.11 - dev: false - - /jsx-ast-utils@3.3.4: - resolution: {integrity: sha512-fX2TVdCViod6HwKEtSWGHs57oFhVfCMwieb9PuRDgjDPh5XeqJiHFFFJCHxU5cnTc3Bu/GRL+kPiFmw8XWOfKw==} - engines: {node: '>=4.0'} - dependencies: - array-includes: 3.1.6 - array.prototype.flat: 1.3.1 - object.assign: 4.1.4 - object.values: 1.1.6 - dev: false - /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} dependencies: - array-includes: 3.1.7 - array.prototype.flat: 1.3.1 + array-includes: 3.1.9 + array.prototype.flat: 1.3.2 object.assign: 4.1.4 - object.values: 1.1.6 - dev: false - - /keyv@3.1.0: - resolution: {integrity: sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==} - dependencies: - json-buffer: 3.0.0 + object.values: 1.1.7 dev: false /kuler@2.0.0: resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==} dev: false + /ky@1.14.3: + resolution: {integrity: sha512-9zy9lkjac+TR1c2tG+mkNSVlyOpInnWdSMiue4F+kq8TwJSgv6o8jhLRg8Ho6SnZ9wOYUq/yozts9qQCfk7bIw==} + engines: {node: '>=18'} + dev: true + /language-subtag-registry@0.3.22: resolution: {integrity: sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==} dev: false @@ -4232,14 +5296,12 @@ packages: language-subtag-registry: 0.3.22 dev: false - /lavaclient@4.1.1: - resolution: {integrity: sha512-j2X7zYGv6WNf4KWNSc6Vl5emSVmNaAsCLbOuUdAeuRzNZ+2JLhXGhBy00m4Qct/wvDkRzujVSVrR7powY6jPUA==} - engines: {node: '>=16.x.x'} + /lavalink-client@2.2.0: + resolution: {integrity: sha512-en5bYBx2avDHaf/vfn0h4E1QGQ5y0PwafDiN+2cDun9CcZOutyi8WaqTkMKwJ0CpwYztHfuF3I8YshlHIvNrSw==} + engines: {node: '>=18.0.0'} dependencies: - '@lavaclient/types': 2.1.1 - tiny-typed-emitter: 2.1.0 - undici: 5.22.1 - ws: 8.13.0 + tslib: 2.8.1 + ws: 8.21.3 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -4252,9 +5314,9 @@ packages: prelude-ls: 1.2.1 type-check: 0.4.0 - /lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} + /lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -4269,12 +5331,13 @@ packages: strip-bom: 3.0.0 dev: false - /locate-path@5.0.0: - resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} - engines: {node: '>=8'} + /local-pkg@0.5.1: + resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==} + engines: {node: '>=14'} dependencies: - p-locate: 4.1.0 - dev: false + mlly: 1.8.2 + pkg-types: 1.3.1 + dev: true /locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} @@ -4284,11 +5347,9 @@ packages: /lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} - dev: false /lodash.isarguments@3.1.0: resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} - dev: false /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -4301,10 +5362,11 @@ packages: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} dev: false - /logform@2.5.1: - resolution: {integrity: sha512-9FyqAm9o9NKKfiAKfZoYo9bGXXuwMkxQiQttkT4YjjVtQVIQtK6LmVtlxmCaFswo6N4AfEkHqZTV0taDtPotNg==} + /logform@2.7.0: + resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} + engines: {node: '>= 12.0.0'} dependencies: - '@colors/colors': 1.5.0 + '@colors/colors': 1.6.0 '@types/triple-beam': 1.3.2 fecha: 4.2.3 ms: 2.1.3 @@ -4319,28 +5381,11 @@ packages: js-tokens: 4.0.0 dev: false - /lowercase-keys@1.0.1: - resolution: {integrity: sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==} - engines: {node: '>=0.10.0'} - dev: false - - /lowercase-keys@2.0.0: - resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} - engines: {node: '>=8'} - dev: false - - /lru-cache@4.1.5: - resolution: {integrity: sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==} - dependencies: - pseudomap: 1.0.2 - yallist: 2.1.2 - dev: false - - /lru-cache@5.1.1: - resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + /loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} dependencies: - yallist: 3.1.1 - dev: false + get-func-name: 2.0.2 + dev: true /lru-cache@6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} @@ -4348,16 +5393,27 @@ packages: dependencies: yallist: 4.0.0 - /lucide-react@0.292.0(react@18.2.0): - resolution: {integrity: sha512-rRgUkpEHWpa5VCT66YscInCQmQuPCB1RFRzkkxMxg4b+jaL0V12E3riWWR2Sh5OIiUhCwGW/ZExuEO4Az32E6Q==} + /lucide-react@1.35.0(react@18.3.1): + resolution: {integrity: sha512-yXCCWxGFYT6bLIPYC4SY6fPQPRs/d797rRIue+J9XP2Td6vQvD53gaQRBCnIVT1kTQRHtAtxlfOQNWAuIF8ELg==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 dependencies: - react: 18.2.0 + react: 18.3.1 + dev: false + + /magic-bytes.js@1.13.1: + resolution: {integrity: sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==} dev: false - /magic-bytes.js@1.5.0: - resolution: {integrity: sha512-wJkXvutRbNWcc37tt5j1HyOK1nosspdh3dj6LUYYAvF6JYNqs53IfRvK9oEpcwiDA1NdoIi64yAMfdivPeVAyw==} + /magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + dev: true + + /math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} dev: false /memorystream@0.3.1: @@ -4365,6 +5421,10 @@ packages: engines: {node: '>= 0.10.0'} dev: false + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + dev: true + /merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -4374,11 +5434,11 @@ packages: engines: {node: '>=10.0.0'} dev: false - /micromatch@4.0.5: - resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + /micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} dependencies: - braces: 3.0.2 + braces: 3.0.3 picomatch: 2.3.1 /mime-db@1.52.0: @@ -4393,19 +5453,34 @@ packages: mime-db: 1.52.0 dev: false - /mimic-response@1.0.1: - resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} - engines: {node: '>=4'} - dev: false + /mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + dev: true /minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} dependencies: brace-expansion: 1.1.11 + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.1.4 + /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + /mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + dependencies: + acorn: 8.18.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + dev: true + /moment@2.29.4: resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==} dev: false @@ -4424,8 +5499,8 @@ packages: object-assign: 4.1.1 thenify-all: 1.6.0 - /nanoid@3.3.6: - resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + /nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -4437,67 +5512,78 @@ packages: hasBin: true dev: false - /next-auth@5.0.0-beta.3(next@14.0.3)(react@18.2.0): - resolution: {integrity: sha512-WOKhATBFGeONV+29HzFmspNmL7NXxrsCWLfaDKmAd/4DD1nqXE0BzNFH8t3SJBx7PUDMnB6F7xB76LM/AaV1MQ==} + /next-auth@5.0.0-beta.32(next@15.2.0)(react@18.3.1): + resolution: {integrity: sha512-CGlChIEWZ6LltNVxrE5yiySMID+Idpmry47JYA5lLwgD8Sx02a8M65VL0TWVz9nbnOioS/tCW/rP/0+mE7Qp4Q==} peerDependencies: - next: ^14 - nodemailer: ^6.6.5 - react: ^18.2.0 + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 || ^8.0.5 + react: ^18.2.0 || ^19.0.0 peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true nodemailer: optional: true dependencies: - '@auth/core': 0.0.0-manual.e9863699 - next: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 + '@auth/core': 0.41.3 + next: 15.2.0(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 dev: false - /next-themes@0.2.1(next@14.0.3)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} + /next-themes@0.4.6(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: - next: '*' - react: '*' - react-dom: '*' + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc dependencies: - next: 14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0) - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) dev: false - /next@14.0.3(@babel/core@7.22.9)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-AbYdRNfImBr3XGtvnwOxq8ekVCwbFTv/UJoLwmaX89nk9i051AEY4/HAWzU0YpaTDw8IofUpmuIlvzWF13jxIw==} - engines: {node: '>=18.17.0'} + /next@15.2.0(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-VaiM7sZYX8KIAHBrRGSFytKknkrexNfGb8GlG6e93JqueCspuGte8i4ybn8z4ww1x3f2uzY4YpTaBEW4/hvsoQ==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. hasBin: true peerDependencies: '@opentelemetry/api': ^1.1.0 - react: ^18.2.0 - react-dom: ^18.2.0 + '@playwright/test': ^1.41.2 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 sass: ^1.3.0 peerDependenciesMeta: '@opentelemetry/api': optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true sass: optional: true dependencies: - '@next/env': 14.0.3 - '@swc/helpers': 0.5.2 + '@next/env': 15.2.0 + '@swc/counter': 0.1.3 + '@swc/helpers': 0.5.15 busboy: 1.6.0 - caniuse-lite: 1.0.30001517 + caniuse-lite: 1.0.30001810 postcss: 8.4.31 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - styled-jsx: 5.1.1(@babel/core@7.22.9)(react@18.2.0) - watchpack: 2.4.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + styled-jsx: 5.1.6(react@18.3.1) optionalDependencies: - '@next/swc-darwin-arm64': 14.0.3 - '@next/swc-darwin-x64': 14.0.3 - '@next/swc-linux-arm64-gnu': 14.0.3 - '@next/swc-linux-arm64-musl': 14.0.3 - '@next/swc-linux-x64-gnu': 14.0.3 - '@next/swc-linux-x64-musl': 14.0.3 - '@next/swc-win32-arm64-msvc': 14.0.3 - '@next/swc-win32-ia32-msvc': 14.0.3 - '@next/swc-win32-x64-msvc': 14.0.3 + '@next/swc-darwin-arm64': 15.2.0 + '@next/swc-darwin-x64': 15.2.0 + '@next/swc-linux-arm64-gnu': 15.2.0 + '@next/swc-linux-arm64-musl': 15.2.0 + '@next/swc-linux-x64-gnu': 15.2.0 + '@next/swc-linux-x64-musl': 15.2.0 + '@next/swc-win32-arm64-msvc': 15.2.0 + '@next/swc-win32-x64-msvc': 15.2.0 + sharp: 0.33.5 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros @@ -4512,6 +5598,16 @@ packages: engines: {node: '>=10.5.0'} dev: false + /node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + dev: false + /node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4521,8 +5617,10 @@ packages: formdata-polyfill: 4.0.10 dev: false - /node-releases@2.0.13: - resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} + /node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + dev: true /normalize-package-data@2.5.0: resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} @@ -4534,18 +5632,8 @@ packages: dev: false /normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - - /normalize-range@0.1.2: - resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} - engines: {node: '>=0.10.0'} - dev: true - - /normalize-url@4.5.1: - resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==} - engines: {node: '>=8'} - dev: false + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} /npm-run-all@4.1.5: resolution: {integrity: sha512-Oo82gJDAVcaMdi3nuoKFavkIHBRVqQ1qvMb+9LHk/cF4P6B2m8aP04hGf7oL6wZ9BuGwX1onlLhpuoofSyoQDQ==} @@ -4563,25 +5651,27 @@ packages: string.prototype.padend: 3.1.4 dev: false + /npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + path-key: 4.0.0 + dev: true + /nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} dependencies: boolbase: 1.0.0 dev: false - /oauth4webapi@2.3.0: - resolution: {integrity: sha512-JGkb5doGrwzVDuHwgrR4nHJayzN4h59VCed6EW8Tql6iHDfZIabCJvg6wtbn5q6pyB2hZruI3b77Nudvq7NmvA==} + /oauth4webapi@3.8.7: + resolution: {integrity: sha512-4RxcKxXjuItDFZ20RRPf4YTw3kpeXJyCgJFxVzJ068A7PNJ18st2Dg90tlC1LkSDS0GecroagCLHYEIVUhCAkw==} dev: false /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - /object-hash@2.2.0: - resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} - engines: {node: '>= 6'} - dev: false - /object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -4590,6 +5680,11 @@ packages: resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} dev: false + /object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + dev: false + /object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -4601,78 +5696,73 @@ packages: dependencies: call-bind: 1.0.2 define-properties: 1.2.0 - has-symbols: 1.0.3 + has-symbols: 1.1.0 object-keys: 1.1.1 dev: false - /object.entries@1.1.6: - resolution: {integrity: sha512-leTPzo4Zvg3pmbQ3rDK69Rl8GQvIqMWubrkxONG9/ojtFE2rD9fjMKfSI5BxW3osRH1m6VdzmqK8oAY9aT4x5w==} + /object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 dev: false - /object.entries@1.1.7: - resolution: {integrity: sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==} + /object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 dev: false - /object.fromentries@2.0.6: - resolution: {integrity: sha512-VciD13dswC4j1Xt5394WR4MzmAQmlgN72phd/riNp9vtD7tp4QQWJ0R4wvclXcafgcYK8veHRed2W6XeGBvcfg==} + /object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 dev: false - /object.fromentries@2.0.7: - resolution: {integrity: sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==} + /object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 dev: false - /object.groupby@1.0.1: - resolution: {integrity: sha512-HqaQtqLnp/8Bn4GL16cj+CUYbnpe1bh0TtEaWvybszDG4tgxCJuRpV8VGuvNaI1fAnI4lUJzDG55MXcOH4JZcQ==} + /object.values@1.1.7: + resolution: {integrity: sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==} + engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 - dev: false - - /object.hasown@1.1.2: - resolution: {integrity: sha512-B5UIT3J1W+WuWIU55h0mjlwaqxiE5vYENJXIXZ4VFe05pNYrkKuK0U/6aFcb0pKywYJh7IhfoqUfKVmrJJHZHw==} - dependencies: - define-properties: 1.2.0 + define-properties: 1.2.1 es-abstract: 1.22.1 dev: false - /object.values@1.1.6: - resolution: {integrity: sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==} + /object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 dev: false - /object.values@1.1.7: - resolution: {integrity: sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==} - engines: {node: '>= 0.4'} - dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 + /on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} dev: false /once@1.4.0: @@ -4686,6 +5776,13 @@ packages: fn.name: 1.1.0 dev: false + /onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + dependencies: + mimic-fn: 4.0.0 + dev: true + /optionator@0.9.3: resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} engines: {node: '>= 0.8.0'} @@ -4697,16 +5794,14 @@ packages: prelude-ls: 1.2.1 type-check: 0.4.0 - /p-cancelable@1.1.0: - resolution: {integrity: sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==} - engines: {node: '>=6'} - dev: false - - /p-limit@2.3.0: - resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} - engines: {node: '>=6'} + /own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} + engines: {node: '>= 0.4'} dependencies: - p-try: 2.2.0 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 dev: false /p-limit@3.1.0: @@ -4715,12 +5810,19 @@ packages: dependencies: yocto-queue: 0.1.0 - /p-locate@4.1.0: - resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} - engines: {node: '>=8'} + /p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} dependencies: - p-limit: 2.3.0 - dev: false + yocto-queue: 1.2.2 + dev: true + + /p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + dependencies: + yocto-queue: 1.2.2 + dev: true /p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} @@ -4728,20 +5830,15 @@ packages: dependencies: p-limit: 3.1.0 - /p-try@2.2.0: - resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} - engines: {node: '>=6'} - dev: false - - /package-json@6.5.0: - resolution: {integrity: sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==} - engines: {node: '>=8'} + /package-json@10.0.1: + resolution: {integrity: sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg==} + engines: {node: '>=18'} dependencies: - got: 9.6.0 - registry-auth-token: 4.2.2 - registry-url: 5.1.0 - semver: 6.3.1 - dev: false + ky: 1.14.3 + registry-auth-token: 5.1.1 + registry-url: 6.0.1 + semver: 7.8.5 + dev: true /parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} @@ -4749,11 +5846,11 @@ packages: dependencies: callsites: 3.1.0 - /parse-github-url@1.0.2: - resolution: {integrity: sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw==} - engines: {node: '>=0.10.0'} + /parse-github-url@1.0.4: + resolution: {integrity: sha512-CEtCOt55fHmd6DpBc/N7H5NC4vJpcquhzzs9Iw2mRj8bVxo1O5TQI5MXKOMO7+yBOqD+5dKCCRK4Kj1KskZc6Q==} + engines: {node: '>= 0.10'} hasBin: true - dev: false + dev: true /parse-json@4.0.0: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} @@ -4793,6 +5890,11 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + /path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + dev: true + /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -4807,13 +5909,29 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} - /picocolors@1.0.0: - resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + /pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + dev: true + + /pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + dev: true + + /pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + dev: true + + /picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} /picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + /picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + /pidtree@0.3.1: resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} engines: {node: '>=0.10'} @@ -4829,62 +5947,122 @@ packages: engines: {node: '>=4'} dev: false - /pify@4.0.1: - resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} - engines: {node: '>=6'} + /pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + dependencies: + split2: 4.2.0 + dev: false + + /pino-pretty@13.1.3: + resolution: {integrity: sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==} + hasBin: true + dependencies: + colorette: 2.0.20 + dateformat: 4.6.3 + fast-copy: 4.1.1 + fast-safe-stringify: 2.1.1 + help-me: 5.0.0 + joycon: 3.1.1 + minimist: 1.2.8 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pump: 3.0.4 + secure-json-parse: 4.1.0 + sonic-boom: 4.2.1 + strip-json-comments: 5.0.3 + dev: false + + /pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + dev: false + + /pino@10.3.1: + resolution: {integrity: sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==} + hasBin: true + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.4.3 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 dev: false /pirates@4.0.6: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} - /postcss-import@15.1.0(postcss@8.4.31): + /pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + dev: true + + /possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + dev: false + + /postcss-import@15.1.0(postcss@8.5.26): resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} peerDependencies: postcss: ^8.0.0 dependencies: - postcss: 8.4.31 + postcss: 8.5.26 postcss-value-parser: 4.2.0 read-cache: 1.0.0 - resolve: 1.22.3 + resolve: 1.22.8 - /postcss-js@4.0.1(postcss@8.4.31): + /postcss-js@4.0.1(postcss@8.5.26): resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} engines: {node: ^12 || ^14 || >= 16} peerDependencies: postcss: ^8.4.21 dependencies: camelcase-css: 2.0.1 - postcss: 8.4.31 + postcss: 8.5.26 - /postcss-load-config@4.0.1(postcss@8.4.31): - resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==} - engines: {node: '>= 14'} + /postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.26): + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} peerDependencies: + jiti: '>=1.21.0' postcss: '>=8.0.9' - ts-node: '>=9.0.0' + tsx: ^4.8.1 + yaml: ^2.4.2 peerDependenciesMeta: + jiti: + optional: true postcss: optional: true - ts-node: + tsx: + optional: true + yaml: optional: true dependencies: - lilconfig: 2.1.0 - postcss: 8.4.31 - yaml: 2.3.1 + jiti: 1.21.7 + lilconfig: 3.1.3 + postcss: 8.5.26 - /postcss-nested@6.0.1(postcss@8.4.31): - resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + /postcss-nested@6.2.0(postcss@8.5.26): + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 dependencies: - postcss: 8.4.31 - postcss-selector-parser: 6.0.13 + postcss: 8.5.26 + postcss-selector-parser: 6.1.4 - /postcss-selector-parser@6.0.13: - resolution: {integrity: sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==} + /postcss-selector-parser@6.1.4: + resolution: {integrity: sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==} engines: {node: '>=4'} dependencies: cssesc: 3.0.0 @@ -4897,104 +6075,122 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} dependencies: - nanoid: 3.3.6 - picocolors: 1.0.0 - source-map-js: 1.0.2 + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + dev: false + + /postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 - /preact-render-to-string@5.2.3(preact@10.11.3): - resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} + /preact-render-to-string@6.5.11(preact@10.24.3): + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} peerDependencies: preact: '>=10' dependencies: - preact: 10.11.3 - pretty-format: 3.8.0 + preact: 10.24.3 dev: false - /preact@10.11.3: - resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} + /preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} dev: false /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} - /prepend-http@2.0.0: - resolution: {integrity: sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==} - engines: {node: '>=4'} - dev: false - - /prettier-plugin-tailwindcss@0.5.7(@ianvs/prettier-plugin-sort-imports@4.1.1)(prettier@3.1.0): - resolution: {integrity: sha512-4v6uESAgwCni6YF6DwJlRaDjg9Z+al5zM4JfngcazMy4WEf/XkPS5TEQjbD+DZ5iNuG6RrKQLa/HuX2SYzC3kQ==} - engines: {node: '>=14.21.3'} + /prettier-plugin-tailwindcss@0.8.1(@ianvs/prettier-plugin-sort-imports@4.7.1)(prettier@3.9.6): + resolution: {integrity: sha512-iaFMYqDsE4ffdDkn5qup0j5f2aCEBFZrdrZnvu9QKTlWx/iGPeQ4HHu7b7fCPMxeo9nwQBiOAh2nSypdFYWJkw==} + engines: {node: '>=20.19'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' '@prettier/plugin-pug': '*' '@shopify/prettier-plugin-liquid': '*' - '@shufo/prettier-plugin-blade': '*' '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' prettier: ^3.0 prettier-plugin-astro: '*' prettier-plugin-css-order: '*' - prettier-plugin-import-sort: '*' prettier-plugin-jsdoc: '*' prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' prettier-plugin-organize-attributes: '*' prettier-plugin-organize-imports: '*' - prettier-plugin-style-order: '*' + prettier-plugin-sort-imports: '*' prettier-plugin-svelte: '*' - prettier-plugin-twig-melody: '*' peerDependenciesMeta: '@ianvs/prettier-plugin-sort-imports': optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true '@prettier/plugin-pug': optional: true '@shopify/prettier-plugin-liquid': optional: true - '@shufo/prettier-plugin-blade': - optional: true '@trivago/prettier-plugin-sort-imports': optional: true + '@zackad/prettier-plugin-twig': + optional: true prettier-plugin-astro: optional: true prettier-plugin-css-order: optional: true - prettier-plugin-import-sort: - optional: true prettier-plugin-jsdoc: optional: true prettier-plugin-marko: optional: true + prettier-plugin-multiline-arrays: + optional: true prettier-plugin-organize-attributes: optional: true prettier-plugin-organize-imports: optional: true - prettier-plugin-style-order: + prettier-plugin-sort-imports: optional: true prettier-plugin-svelte: optional: true - prettier-plugin-twig-melody: - optional: true dependencies: - '@ianvs/prettier-plugin-sort-imports': 4.1.1(prettier@3.1.0) - prettier: 3.1.0 - dev: false + '@ianvs/prettier-plugin-sort-imports': 4.7.1(prettier@3.9.6) + prettier: 3.9.6 + dev: true - /prettier@3.1.0: - resolution: {integrity: sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==} + /prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} engines: {node: '>=14'} hasBin: true + dev: true - /pretty-format@3.8.0: - resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} - dev: false + /pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + dev: true - /prisma@5.6.0: - resolution: {integrity: sha512-EEaccku4ZGshdr2cthYHhf7iyvCcXqwJDvnoQRAJg5ge2Tzpv0e2BaMCp+CbbDUwoVTzwgOap9Zp+d4jFa2O9A==} + /prisma@5.22.0: + resolution: {integrity: sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==} engines: {node: '>=16.13'} hasBin: true requiresBuild: true dependencies: - '@prisma/engines': 5.6.0 + '@prisma/engines': 5.22.0 + optionalDependencies: + fsevents: 2.3.3 + + /process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + dev: false /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -5004,18 +6200,19 @@ packages: react-is: 16.13.1 dev: false - /proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false + /proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + dev: true - /pseudomap@1.0.2: - resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} + /proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} dev: false - /pump@3.0.0: - resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} + /pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} dependencies: - end-of-stream: 1.4.4 + end-of-stream: 1.4.5 once: 1.4.0 dev: false @@ -5026,6 +6223,10 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + /quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + dev: false + /rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -5034,84 +6235,79 @@ packages: ini: 1.3.8 minimist: 1.2.8 strip-json-comments: 2.0.1 - dev: false + dev: true - /react-dom@18.2.0(react@18.2.0): - resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + /react-dom@18.3.1(react@18.3.1): + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: - react: ^18.2.0 + react: ^18.3.1 dependencies: loose-envify: 1.4.0 - react: 18.2.0 - scheduler: 0.23.0 + react: 18.3.1 + scheduler: 0.23.2 dev: false /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} dev: false - /react-remove-scroll-bar@2.3.4(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==} + /react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + dev: true + + /react-remove-scroll-bar@2.3.8(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 peerDependenciesMeta: '@types/react': optional: true dependencies: - '@types/react': 18.2.38 - react: 18.2.0 - react-style-singleton: 2.2.1(@types/react@18.2.38)(react@18.2.0) - tslib: 2.6.2 + '@types/react': 18.3.31 + react: 18.3.1 + react-style-singleton: 2.2.3(@types/react@18.3.31)(react@18.3.1) + tslib: 2.8.1 dev: false - /react-remove-scroll@2.5.5(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==} + /react-remove-scroll@2.7.2(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: - '@types/react': 18.2.38 - react: 18.2.0 - react-remove-scroll-bar: 2.3.4(@types/react@18.2.38)(react@18.2.0) - react-style-singleton: 2.2.1(@types/react@18.2.38)(react@18.2.0) - tslib: 2.6.2 - use-callback-ref: 1.3.0(@types/react@18.2.38)(react@18.2.0) - use-sidecar: 1.1.2(@types/react@18.2.38)(react@18.2.0) + '@types/react': 18.3.31 + react: 18.3.1 + react-remove-scroll-bar: 2.3.8(@types/react@18.3.31)(react@18.3.1) + react-style-singleton: 2.2.3(@types/react@18.3.31)(react@18.3.1) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@18.3.31)(react@18.3.1) + use-sidecar: 1.1.3(@types/react@18.3.31)(react@18.3.1) dev: false - /react-ssr-prepass@1.5.0(react@18.2.0): - resolution: {integrity: sha512-yFNHrlVEReVYKsLI5lF05tZoHveA5pGzjFbFJY/3pOqqjGOmMmqx83N4hIjN2n6E1AOa+eQEUxs3CgRnPmT0RQ==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - dependencies: - react: 18.2.0 - dev: false - - /react-style-singleton@2.2.1(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} + /react-style-singleton@2.2.3(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: - '@types/react': 18.2.38 + '@types/react': 18.3.31 get-nonce: 1.0.1 - invariant: 2.2.4 - react: 18.2.0 - tslib: 2.6.2 + react: 18.3.1 + tslib: 2.8.1 dev: false - /react@18.2.0: - resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + /react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} dependencies: loose-envify: 1.4.0 @@ -5131,16 +6327,6 @@ packages: path-type: 3.0.0 dev: false - /read-yaml-file@1.1.0: - resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} - engines: {node: '>=6'} - dependencies: - graceful-fs: 4.2.11 - js-yaml: 3.14.1 - pify: 4.0.1 - strip-bom: 3.0.0 - dev: false - /readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -5156,32 +6342,42 @@ packages: dependencies: picomatch: 2.3.1 + /readline-sync@1.4.10: + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} + engines: {node: '>= 0.8.0'} + dev: false + + /real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + dev: false + + /real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + dev: false + /redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} - dev: false /redis-parser@3.0.0: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} dependencies: redis-errors: 1.2.0 - dev: false - /reflect.getprototypeof@1.0.4: - resolution: {integrity: sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==} + /reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 + call-bind: 1.0.9 define-properties: 1.2.1 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 - globalthis: 1.0.3 - which-builtin-type: 1.1.3 - dev: false - - /regenerator-runtime@0.13.11: - resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 dev: false /regenerator-runtime@0.14.0: @@ -5197,19 +6393,31 @@ packages: functions-have-names: 1.2.3 dev: false - /registry-auth-token@4.2.2: - resolution: {integrity: sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg==} - engines: {node: '>=6.0.0'} + /regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} dependencies: - rc: 1.2.8 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 dev: false - /registry-url@5.1.0: - resolution: {integrity: sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==} - engines: {node: '>=8'} + /registry-auth-token@5.1.1: + resolution: {integrity: sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==} + engines: {node: '>=14'} + dependencies: + '@pnpm/npm-conf': 3.0.3 + dev: true + + /registry-url@6.0.1: + resolution: {integrity: sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==} + engines: {node: '>=12'} dependencies: rc: 1.2.8 - dev: false + dev: true /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} @@ -5219,19 +6427,11 @@ packages: resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} hasBin: true dependencies: - is-core-module: 2.12.1 + is-core-module: 2.13.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 dev: false - /resolve@1.22.3: - resolution: {integrity: sha512-P8ur/gp/AmbEzjr729bZnLjXK5Z+4P0zhIJgBgzqRih7hL7BOukHGtSTA3ACMY467GRFz3duQsi0bDZdR7DKdw==} - hasBin: true - dependencies: - is-core-module: 2.12.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - /resolve@1.22.8: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true @@ -5239,33 +6439,67 @@ packages: is-core-module: 2.13.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - dev: false - /resolve@2.0.0-next.4: - resolution: {integrity: sha512-iMDbmAWtfU+MHpxt/I5iWI7cY6YVEZUQ3MBgPQ++XD1PELuJHIl82xBmObyP2KyQmkNB2dsqF7seoQQiAn5yDQ==} + /resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} hasBin: true dependencies: - is-core-module: 2.12.1 + es-errors: 1.3.0 + is-core-module: 2.16.2 + node-exports-info: 1.6.2 + object-keys: 1.1.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 dev: false - /responselike@1.0.2: - resolution: {integrity: sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==} - dependencies: - lowercase-keys: 1.0.1 - dev: false - /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 + /rollup@4.63.1: + resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.1 + '@rollup/rollup-android-arm64': 4.63.1 + '@rollup/rollup-darwin-arm64': 4.63.1 + '@rollup/rollup-darwin-x64': 4.63.1 + '@rollup/rollup-freebsd-arm64': 4.63.1 + '@rollup/rollup-freebsd-x64': 4.63.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.1 + '@rollup/rollup-linux-arm-musleabihf': 4.63.1 + '@rollup/rollup-linux-arm64-gnu': 4.63.1 + '@rollup/rollup-linux-arm64-musl': 4.63.1 + '@rollup/rollup-linux-loong64-gnu': 4.63.1 + '@rollup/rollup-linux-loong64-musl': 4.63.1 + '@rollup/rollup-linux-ppc64-gnu': 4.63.1 + '@rollup/rollup-linux-ppc64-musl': 4.63.1 + '@rollup/rollup-linux-riscv64-gnu': 4.63.1 + '@rollup/rollup-linux-riscv64-musl': 4.63.1 + '@rollup/rollup-linux-s390x-gnu': 4.63.1 + '@rollup/rollup-linux-x64-gnu': 4.63.1 + '@rollup/rollup-linux-x64-musl': 4.63.1 + '@rollup/rollup-openbsd-x64': 4.63.1 + '@rollup/rollup-openharmony-arm64': 4.63.1 + '@rollup/rollup-win32-arm64-msvc': 4.63.1 + '@rollup/rollup-win32-ia32-msvc': 4.63.1 + '@rollup/rollup-win32-x64-gnu': 4.63.1 + '@rollup/rollup-win32-x64-msvc': 4.63.1 + fsevents: 2.3.3 + dev: true + /run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} dependencies: @@ -5276,18 +6510,19 @@ packages: engines: {node: '>=0.4'} dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.1 - has-symbols: 1.0.3 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 isarray: 2.0.5 dev: false - /safe-array-concat@1.0.1: - resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} + /safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} dependencies: - call-bind: 1.0.2 - get-intrinsic: 1.2.1 - has-symbols: 1.0.3 + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 isarray: 2.0.5 dev: false @@ -5295,32 +6530,52 @@ packages: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: false + /safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + dev: false + /safe-regex-test@1.0.0: resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==} dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.1 + get-intrinsic: 1.3.0 is-regex: 1.1.4 dev: false + /safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + dev: false + /safe-stable-stringify@2.4.3: resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} engines: {node: '>=10'} dev: false - /scheduler@0.23.0: - resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + /scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} dependencies: loose-envify: 1.4.0 dev: false - /sembear@0.5.2: - resolution: {integrity: sha512-Ij1vCAdFgWABd7zTg50Xw1/p0JgESNxuLlneEAsmBrKishA06ulTTL/SHGmNy2Zud7+rKrHTKNI6moJsn1ppAQ==} - dependencies: - '@types/semver': 6.2.3 - semver: 6.3.1 + /secure-json-parse@4.1.0: + resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==} dev: false + /sembear@0.7.0: + resolution: {integrity: sha512-XyLTEich2D02FODCkfdto3mB9DetWPLuTzr4tvoofe9SvyM27h4nQSbV3+iVcYQz94AFyKtqBv5pcZbj3k2hdA==} + dependencies: + semver: 7.8.5 + dev: true + /semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -5338,14 +6593,72 @@ packages: dependencies: lru-cache: 6.0.0 - /set-function-name@2.0.1: - resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} + /semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + /set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} dependencies: - define-data-property: 1.1.1 + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + dev: false + + /set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 functions-have-names: 1.2.3 - has-property-descriptors: 1.0.0 + has-property-descriptors: 1.0.2 + dev: false + + /set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + dev: false + + /sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + requiresBuild: true + dependencies: + color: 4.2.3 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 dev: false + optional: true /shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} @@ -5365,47 +6678,92 @@ packages: engines: {node: '>=0.10.0'} dev: false - /shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - /shell-quote@1.8.1: - resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + /shell-quote@1.8.1: + resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + dev: false + + /side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + dev: false + + /side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + dev: false + + /side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 dev: false /side-channel@1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.1 + get-intrinsic: 1.3.0 object-inspect: 1.12.3 dev: false - /signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + /side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 dev: false - /simple-swizzle@0.2.2: - resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + /siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + dev: true + + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: true + + /simple-swizzle@0.2.4: + resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} dependencies: - is-arrayish: 0.3.2 + is-arrayish: 0.3.4 dev: false + optional: true /slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} - /source-map-js@1.0.2: - resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} - engines: {node: '>=0.10.0'} - - /spawndamnit@2.0.0: - resolution: {integrity: sha512-j4JKEcncSjFlqIwU5L/rp2N5SIPsdxaRsIv678+TZxZ0SRDJTm8JrxJMjE/XuiEZNEir3S8l0Fa3Ke339WI4qA==} + /sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} dependencies: - cross-spawn: 5.1.0 - signal-exit: 3.0.7 + atomic-sleep: 1.0.0 dev: false + /source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + /spdx-correct@3.2.0: resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} dependencies: @@ -5428,16 +6786,36 @@ packages: resolution: {integrity: sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==} dev: false - /sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: false + + /sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} dev: false /stack-trace@0.0.10: resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} dev: false + /stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + dev: true + /standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + + /std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + dev: true + + /stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 dev: false /streamsearch@1.1.0: @@ -5449,17 +6827,32 @@ packages: resolution: {integrity: sha512-OpkcFxlFjn7kz5jTWmPGY+FFJVN21lQ9k0fkK0XS5GVvlCgS1stgDNEoMyqnkbZEcVP3Gv6IxqgG7tnD7ChBSw==} dev: false - /string.prototype.matchall@4.0.8: - resolution: {integrity: sha512-6zOCOcJ+RJAQshcTvXPHoxoQGONa3e/Lqx90wUA+wEzX78sg5Bo+1tQo4N0pohS0erG9qtCqJDjNCQBjeWVxyg==} + /string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} dependencies: - call-bind: 1.0.2 - define-properties: 1.2.0 - es-abstract: 1.22.1 - get-intrinsic: 1.2.1 - has-symbols: 1.0.3 - internal-slot: 1.0.5 - regexp.prototype.flags: 1.5.0 - side-channel: 1.0.4 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + dev: false + + /string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 dev: false /string.prototype.padend@3.1.4: @@ -5471,6 +6864,27 @@ packages: es-abstract: 1.22.1 dev: false + /string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + dependencies: + define-properties: 1.2.1 + es-abstract: 1.22.1 + dev: false + + /string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + dev: false + /string.prototype.trim@1.2.7: resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} engines: {node: '>= 0.4'} @@ -5480,6 +6894,16 @@ packages: es-abstract: 1.22.1 dev: false + /string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + dev: false + /string.prototype.trimend@1.0.6: resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==} dependencies: @@ -5496,6 +6920,15 @@ packages: es-abstract: 1.22.1 dev: false + /string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + dev: false + /string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: @@ -5513,44 +6946,59 @@ packages: engines: {node: '>=4'} dev: false + /strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + dev: true + /strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} - dev: false + dev: true /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} - /styled-jsx@5.1.1(@babel/core@7.22.9)(react@18.2.0): - resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + /strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + dev: false + + /strip-literal@2.1.1: + resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} + dependencies: + js-tokens: 9.0.1 + dev: true + + /styled-jsx@5.1.6(react@18.3.1): + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} peerDependencies: '@babel/core': '*' babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' peerDependenciesMeta: '@babel/core': optional: true babel-plugin-macros: optional: true dependencies: - '@babel/core': 7.22.9 client-only: 0.0.1 - react: 18.2.0 + react: 18.3.1 dev: false - /sucrase@3.34.0: - resolution: {integrity: sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==} - engines: {node: '>=8'} + /sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} hasBin: true dependencies: - '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/gen-mapping': 0.3.13 commander: 4.1.1 - glob: 7.1.6 lines-and-columns: 1.2.4 mz: 2.7.0 pirates: 4.0.6 + tinyglobby: 0.2.17 ts-interface-checker: 0.1.13 /superjson@1.13.3: @@ -5583,43 +7031,44 @@ packages: '@babel/runtime': 7.23.4 dev: false - /tailwindcss-animate@1.0.7(tailwindcss@3.3.5): + /tailwindcss-animate@1.0.7(tailwindcss@3.4.19): resolution: {integrity: sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==} peerDependencies: tailwindcss: '>=3.0.0 || insiders' dependencies: - tailwindcss: 3.3.5 + tailwindcss: 3.4.19 dev: false - /tailwindcss@3.3.5: - resolution: {integrity: sha512-5SEZU4J7pxZgSkv7FP1zY8i2TIAOooNZ1e/OGtxIEv6GltpoiXUqWvLy89+a10qYTB1N5Ifkuw9lqQkN9sscvA==} + /tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} hasBin: true dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 - chokidar: 3.5.3 + chokidar: 3.6.0 didyoumean: 1.2.2 dlv: 1.1.3 - fast-glob: 3.3.1 + fast-glob: 3.3.3 glob-parent: 6.0.2 is-glob: 4.0.3 - jiti: 1.19.1 - lilconfig: 2.1.0 - micromatch: 4.0.5 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 normalize-path: 3.0.0 object-hash: 3.0.0 - picocolors: 1.0.0 - postcss: 8.4.31 - postcss-import: 15.1.0(postcss@8.4.31) - postcss-js: 4.0.1(postcss@8.4.31) - postcss-load-config: 4.0.1(postcss@8.4.31) - postcss-nested: 6.0.1(postcss@8.4.31) - postcss-selector-parser: 6.0.13 - resolve: 1.22.3 - sucrase: 3.34.0 + picocolors: 1.1.1 + postcss: 8.5.26 + postcss-import: 15.1.0(postcss@8.5.26) + postcss-js: 4.0.1(postcss@8.5.26) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.26) + postcss-nested: 6.2.0(postcss@8.5.26) + postcss-selector-parser: 6.1.4 + resolve: 1.22.8 + sucrase: 3.35.1 transitivePeerDependencies: - - ts-node + - tsx + - yaml /text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} @@ -5639,18 +7088,42 @@ packages: dependencies: any-promise: 1.3.0 - /tiny-typed-emitter@2.1.0: - resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + /thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + dependencies: + real-require: 1.0.0 dev: false - /to-fast-properties@2.0.0: - resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} - engines: {node: '>=4'} - dev: false + /tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + dev: true - /to-readable-stream@1.0.0: - resolution: {integrity: sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==} - engines: {node: '>=6'} + /tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + dev: true + + /tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + /tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + dev: true + + /tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + dev: true + + /tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} dev: false /to-regex-range@5.0.1: @@ -5664,13 +7137,13 @@ packages: engines: {node: '>= 14.0.0'} dev: false - /ts-api-utils@1.0.1(typescript@5.3.2): + /ts-api-utils@1.0.1(typescript@5.9.3): resolution: {integrity: sha512-lC/RGlPmwdrIBFTX59wwNzqh7aR2otPNPR/5brHZm/XKFYKsfqxihXUe9pU3JI+3vGkl+vyCoNNnPhJn3aLK1A==} engines: {node: '>=16.13.0'} peerDependencies: typescript: '>=4.2.0' dependencies: - typescript: 5.3.2 + typescript: 5.9.3 /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -5679,8 +7152,12 @@ packages: resolution: {integrity: sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==} dev: false - /tsconfig-paths@3.14.2: - resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} + /ts-mixer@6.0.4: + resolution: {integrity: sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==} + dev: false + + /tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} dependencies: '@types/json5': 0.0.29 json5: 1.0.2 @@ -5688,68 +7165,68 @@ packages: strip-bom: 3.0.0 dev: false - /tslib@2.6.2: - resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + /tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - /turbo-darwin-64@1.10.16: - resolution: {integrity: sha512-+Jk91FNcp9e9NCLYlvDDlp2HwEDp14F9N42IoW3dmHI5ZkGSXzalbhVcrx3DOox3QfiNUHxzWg4d7CnVNCuuMg==} + /turbo-darwin-64@1.13.4: + resolution: {integrity: sha512-A0eKd73R7CGnRinTiS7txkMElg+R5rKFp9HV7baDiEL4xTG1FIg/56Vm7A5RVgg8UNgG2qNnrfatJtb+dRmNdw==} cpu: [x64] os: [darwin] requiresBuild: true - dev: false + dev: true optional: true - /turbo-darwin-arm64@1.10.16: - resolution: {integrity: sha512-jqGpFZipIivkRp/i+jnL8npX0VssE6IAVNKtu573LXtssZdV/S+fRGYA16tI46xJGxSAivrZ/IcgZrV6Jk80bw==} + /turbo-darwin-arm64@1.13.4: + resolution: {integrity: sha512-eG769Q0NF6/Vyjsr3mKCnkG/eW6dKMBZk6dxWOdrHfrg6QgfkBUk0WUUujzdtVPiUIvsh4l46vQrNVd9EOtbyA==} cpu: [arm64] os: [darwin] requiresBuild: true - dev: false + dev: true optional: true - /turbo-linux-64@1.10.16: - resolution: {integrity: sha512-PpqEZHwLoizQ6sTUvmImcRmACyRk9EWLXGlqceogPZsJ1jTRK3sfcF9fC2W56zkSIzuLEP07k5kl+ZxJd8JMcg==} + /turbo-linux-64@1.13.4: + resolution: {integrity: sha512-Bq0JphDeNw3XEi+Xb/e4xoKhs1DHN7OoLVUbTIQz+gazYjigVZvtwCvgrZI7eW9Xo1eOXM2zw2u1DGLLUfmGkQ==} cpu: [x64] os: [linux] requiresBuild: true - dev: false + dev: true optional: true - /turbo-linux-arm64@1.10.16: - resolution: {integrity: sha512-TMjFYz8to1QE0fKVXCIvG/4giyfnmqcQIwjdNfJvKjBxn22PpbjeuFuQ5kNXshUTRaTJihFbuuCcb5OYFNx4uw==} + /turbo-linux-arm64@1.13.4: + resolution: {integrity: sha512-BJcXw1DDiHO/okYbaNdcWN6szjXyHWx9d460v6fCHY65G8CyqGU3y2uUTPK89o8lq/b2C8NK0yZD+Vp0f9VoIg==} cpu: [arm64] os: [linux] requiresBuild: true - dev: false + dev: true optional: true - /turbo-windows-64@1.10.16: - resolution: {integrity: sha512-+jsf68krs0N66FfC4/zZvioUap/Tq3sPFumnMV+EBo8jFdqs4yehd6+MxIwYTjSQLIcpH8KoNMB0gQYhJRLZzw==} + /turbo-windows-64@1.13.4: + resolution: {integrity: sha512-OFFhXHOFLN7A78vD/dlVuuSSVEB3s9ZBj18Tm1hk3aW1HTWTuAw0ReN6ZNlVObZUHvGy8d57OAGGxf2bT3etQw==} cpu: [x64] os: [win32] requiresBuild: true - dev: false + dev: true optional: true - /turbo-windows-arm64@1.10.16: - resolution: {integrity: sha512-sKm3hcMM1bl0B3PLG4ifidicOGfoJmOEacM5JtgBkYM48ncMHjkHfFY7HrJHZHUnXM4l05RQTpLFoOl/uIo2HQ==} + /turbo-windows-arm64@1.13.4: + resolution: {integrity: sha512-u5A+VOKHswJJmJ8o8rcilBfU5U3Y1TTAfP9wX8bFh8teYF1ghP0EhtMRLjhtp6RPa+XCxHHVA2CiC3gbh5eg5g==} cpu: [arm64] os: [win32] requiresBuild: true - dev: false + dev: true optional: true - /turbo@1.10.16: - resolution: {integrity: sha512-2CEaK4FIuSZiP83iFa9GqMTQhroW2QryckVqUydmg4tx78baftTOS0O+oDAhvo9r9Nit4xUEtC1RAHoqs6ZEtg==} + /turbo@1.13.4: + resolution: {integrity: sha512-1q7+9UJABuBAHrcC4Sxp5lOqYS5mvxRrwa33wpIyM18hlOCpRD/fTJNxZ0vhbMcJmz15o9kkVm743mPn7p6jpQ==} hasBin: true optionalDependencies: - turbo-darwin-64: 1.10.16 - turbo-darwin-arm64: 1.10.16 - turbo-linux-64: 1.10.16 - turbo-linux-arm64: 1.10.16 - turbo-windows-64: 1.10.16 - turbo-windows-arm64: 1.10.16 - dev: false + turbo-darwin-64: 1.13.4 + turbo-darwin-arm64: 1.13.4 + turbo-linux-64: 1.13.4 + turbo-linux-arm64: 1.13.4 + turbo-windows-64: 1.13.4 + turbo-windows-arm64: 1.13.4 + dev: true /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -5757,6 +7234,11 @@ packages: dependencies: prelude-ls: 1.2.1 + /type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + dev: true + /type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -5766,10 +7248,19 @@ packages: engines: {node: '>= 0.4'} dependencies: call-bind: 1.0.2 - get-intrinsic: 1.2.1 + get-intrinsic: 1.3.0 is-typed-array: 1.1.12 dev: false + /typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + dev: false + /typed-array-byte-length@1.0.0: resolution: {integrity: sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==} engines: {node: '>= 0.4'} @@ -5780,6 +7271,17 @@ packages: is-typed-array: 1.1.12 dev: false + /typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + for-each: 0.3.3 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + dev: false + /typed-array-byte-offset@1.0.0: resolution: {integrity: sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==} engines: {node: '>= 0.4'} @@ -5791,6 +7293,19 @@ packages: is-typed-array: 1.1.12 dev: false + /typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.3 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + dev: false + /typed-array-length@1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} dependencies: @@ -5799,62 +7314,69 @@ packages: is-typed-array: 1.1.12 dev: false - /typescript@5.3.2: - resolution: {integrity: sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==} + /typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + dev: false + + /typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} hasBin: true + /ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + dev: true + /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: call-bind: 1.0.2 has-bigints: 1.0.2 - has-symbols: 1.0.3 + has-symbols: 1.1.0 which-boxed-primitive: 1.0.2 dev: false - /undici-types@5.26.5: - resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - - /undici@5.22.1: - resolution: {integrity: sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==} - engines: {node: '>=14.0'} - dependencies: - busboy: 1.6.0 - dev: false - - /undici@5.27.2: - resolution: {integrity: sha512-iS857PdOEy/y3wlM3yRp+6SNQQ6xU0mmZcwRSriqk+et/cwWAtwmIGf6WkoDN2EK/AMdCO/dfXzIwi+rFMrjjQ==} - engines: {node: '>=14.0'} + /unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} dependencies: - '@fastify/busboy': 2.1.0 + call-bound: 1.0.4 + has-bigints: 1.0.2 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 dev: false - /universalify@0.1.2: - resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} - engines: {node: '>= 4.0.0'} - dev: false + /undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - /update-browserslist-db@1.0.11(browserslist@4.21.9): - resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - dependencies: - browserslist: 4.21.9 - escalade: 3.1.1 - picocolors: 1.0.0 + /undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} dev: false - /update-browserslist-db@1.0.13(browserslist@4.22.1): - resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + /update-browserslist-db@1.3.2(browserslist@4.28.8): + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} hasBin: true peerDependencies: browserslist: '>= 4.21.0' dependencies: - browserslist: 4.22.1 - escalade: 3.1.1 - picocolors: 1.0.0 + browserslist: 4.28.8 + escalade: 3.2.0 + picocolors: 1.1.1 dev: true /uri-js@4.4.1: @@ -5862,47 +7384,51 @@ packages: dependencies: punycode: 2.3.0 - /url-parse-lax@3.0.0: - resolution: {integrity: sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==} - engines: {node: '>=4'} - dependencies: - prepend-http: 2.0.0 - dev: false - - /use-callback-ref@1.3.0(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==} + /use-callback-ref@1.3.3(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: - '@types/react': 18.2.38 - react: 18.2.0 - tslib: 2.6.2 + '@types/react': 18.3.31 + react: 18.3.1 + tslib: 2.8.1 dev: false - /use-sidecar@1.1.2(@types/react@18.2.38)(react@18.2.0): - resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} + /use-sidecar@1.1.3(@types/react@18.3.31)(react@18.3.1): + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} engines: {node: '>=10'} peerDependencies: - '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc peerDependenciesMeta: '@types/react': optional: true dependencies: - '@types/react': 18.2.38 + '@types/react': 18.3.31 detect-node-es: 1.1.0 - react: 18.2.0 - tslib: 2.6.2 + react: 18.3.1 + tslib: 2.8.1 dev: false /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + /valibot@1.5.0(typescript@5.9.3): + resolution: {integrity: sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.9.3 + dev: false + /validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} dependencies: @@ -5910,19 +7436,128 @@ packages: spdx-expression-parse: 3.0.1 dev: false - /validate-npm-package-name@3.0.0: - resolution: {integrity: sha512-M6w37eVCMMouJ9V/sdPGnC5H4uDr73/+xdq0FBLO3TFFX1+7wiUY6Es328NN+y43tmY+doUdN9g9J21vqB7iLw==} + /validate-npm-package-name@6.0.2: + resolution: {integrity: sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ==} + engines: {node: ^18.17.0 || >=20.5.0} + dev: true + + /vite-node@1.6.0(@types/node@20.19.43): + resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true dependencies: - builtins: 1.0.3 - dev: false + cac: 6.7.14 + debug: 4.3.4 + pathe: 1.1.2 + picocolors: 1.1.1 + vite: 5.4.21(@types/node@20.19.43) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + dev: true - /watchpack@2.4.0: - resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} - engines: {node: '>=10.13.0'} + /vite@5.4.21(@types/node@20.19.43): + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - dev: false + '@types/node': 20.19.43 + esbuild: 0.21.5 + postcss: 8.5.26 + rollup: 4.63.1 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /vitest@1.6.0(@types/node@20.19.43): + resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.0 + '@vitest/ui': 1.6.0 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/node': 20.19.43 + '@vitest/expect': 1.6.0 + '@vitest/runner': 1.6.0 + '@vitest/snapshot': 1.6.0 + '@vitest/spy': 1.6.0 + '@vitest/utils': 1.6.0 + acorn-walk: 8.3.5 + chai: 4.5.0 + debug: 4.3.4 + execa: 8.0.1 + local-pkg: 0.5.1 + magic-string: 0.30.21 + pathe: 1.1.2 + picocolors: 1.1.1 + std-env: 3.10.0 + strip-literal: 2.1.1 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.21(@types/node@20.19.43) + vite-node: 1.6.0(@types/node@20.19.43) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + dev: true /web-streams-polyfill@3.2.1: resolution: {integrity: sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==} @@ -5939,31 +7574,44 @@ packages: is-symbol: 1.0.4 dev: false - /which-builtin-type@1.1.3: - resolution: {integrity: sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==} + /which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} dependencies: - function.prototype.name: 1.1.5 - has-tostringtag: 1.0.0 + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + dev: false + + /which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 is-async-function: 2.0.0 - is-date-object: 1.0.5 - is-finalizationregistry: 1.0.2 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 is-generator-function: 1.0.10 - is-regex: 1.1.4 - is-weakref: 1.0.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 isarray: 2.0.5 - which-boxed-primitive: 1.0.2 - which-collection: 1.0.1 - which-typed-array: 1.1.11 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.22 dev: false - /which-collection@1.0.1: - resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==} + /which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} dependencies: - is-map: 2.0.2 - is-set: 2.0.2 - is-weakmap: 2.0.1 - is-weakset: 2.0.2 + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 dev: false /which-typed-array@1.1.11: @@ -5973,8 +7621,21 @@ packages: available-typed-arrays: 1.0.5 call-bind: 1.0.2 for-each: 0.3.3 - gopd: 1.0.1 - has-tostringtag: 1.0.0 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + dev: false + + /which-typed-array@1.1.22: + resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 dev: false /which@1.3.1: @@ -5991,63 +7652,59 @@ packages: dependencies: isexe: 2.0.0 - /winston-daily-rotate-file@4.7.1(winston@3.11.0): - resolution: {integrity: sha512-7LGPiYGBPNyGHLn9z33i96zx/bd71pjBn9tqQzO3I4Tayv94WPmBNwKC7CO1wPHdP9uvu+Md/1nr6VSH9h0iaA==} + /why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + dev: true + + /winston-daily-rotate-file@5.0.0(winston@3.19.0): + resolution: {integrity: sha512-JDjiXXkM5qvwY06733vf09I2wnMXpZEhxEVOSPenZMii+g7pcDcTBt2MRugnoi8BwVSuCT2jfRXBUy+n1Zz/Yw==} engines: {node: '>=8'} peerDependencies: winston: ^3 dependencies: file-stream-rotator: 0.6.1 - object-hash: 2.2.0 + object-hash: 3.0.0 triple-beam: 1.4.1 - winston: 3.11.0 - winston-transport: 4.5.0 + winston: 3.19.0 + winston-transport: 4.9.0 dev: false - /winston-transport@4.5.0: - resolution: {integrity: sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==} - engines: {node: '>= 6.4.0'} + /winston-transport@4.9.0: + resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} + engines: {node: '>= 12.0.0'} dependencies: - logform: 2.5.1 + logform: 2.7.0 readable-stream: 3.6.2 triple-beam: 1.4.1 dev: false - /winston@3.11.0: - resolution: {integrity: sha512-L3yR6/MzZAOl0DsysUXHVjOwv8mKZ71TrA/41EIduGpOOV5LQVodqN+QdQ6BS6PJ/RdIshZhq84P/fStEZkk7g==} + /winston@3.19.0: + resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} engines: {node: '>= 12.0.0'} dependencies: '@colors/colors': 1.6.0 - '@dabh/diagnostics': 2.0.3 + '@dabh/diagnostics': 2.0.8 async: 3.2.4 is-stream: 2.0.1 - logform: 2.5.1 + logform: 2.7.0 one-time: 1.0.0 readable-stream: 3.6.2 safe-stable-stringify: 2.4.3 stack-trace: 0.0.10 triple-beam: 1.4.1 - winston-transport: 4.5.0 + winston-transport: 4.9.0 dev: false /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - /ws@8.13.0: - resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - dev: false - - /ws@8.14.2: - resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==} + /ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -6059,25 +7716,57 @@ packages: optional: true dev: false - /yallist@2.1.2: - resolution: {integrity: sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==} - dev: false - - /yallist@3.1.1: - resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - dev: false - /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - /yaml@2.3.1: - resolution: {integrity: sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==} - engines: {node: '>= 14'} + /yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + dev: true /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - /zod@3.22.4: - resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + /yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + dev: true + + /zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + dev: false + + '@github.com/HELIX-Origin/Lavalink-Server/releases/download/v1.1.0/helix-origin-lavalink-server-1.1.0.tgz(@types/ioredis-mock@8.2.5)(ioredis@5.6.1)(typescript@5.9.3)': + resolution: {tarball: https://github.com/HELIX-Origin/Lavalink-Server/releases/download/v1.1.0/helix-origin-lavalink-server-1.1.0.tgz} + id: '@github.com/HELIX-Origin/Lavalink-Server/releases/download/v1.1.0/helix-origin-lavalink-server-1.1.0.tgz' + name: '@helix-origin/lavalink-server' + version: 1.1.0 + dependencies: + dotenv: 17.4.2 + ioredis-mock: 8.13.1(@types/ioredis-mock@8.2.5)(ioredis@5.6.1) + js-yaml: 5.4.2 + pino: 10.3.1 + pino-pretty: 13.1.3 + valibot: 1.5.0(typescript@5.9.3) + ws: 8.21.3 + transitivePeerDependencies: + - '@types/ioredis-mock' + - bufferutil + - ioredis + - typescript + - utf-8-validate dev: false + + '@github.com/HELIX-Origin/vitest-suite/releases/download/v0.2.3/helix-origin-vitest-suite-0.2.3.tgz(vitest@1.6.0)': + resolution: {tarball: https://github.com/HELIX-Origin/vitest-suite/releases/download/v0.2.3/helix-origin-vitest-suite-0.2.3.tgz} + id: '@github.com/HELIX-Origin/vitest-suite/releases/download/v0.2.3/helix-origin-vitest-suite-0.2.3.tgz' + name: '@helix-origin/vitest-suite' + version: 0.2.3 + engines: {node: '>=20.0.0'} + peerDependencies: + vitest: '>=1.0.0' + dependencies: + vitest: 1.6.0(@types/node@20.19.43) + dev: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7775d25e9..68fd4b086 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,6 @@ packages: - apps/dashboard - apps/bot - - packages/api - packages/auth - packages/db - packages/config/* diff --git a/scripts/common.mjs b/scripts/common.mjs new file mode 100644 index 000000000..d525fc650 --- /dev/null +++ b/scripts/common.mjs @@ -0,0 +1,382 @@ +import { execSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +export const rootDir = path.resolve(__dirname, '..'); +export const logsDir = path.join(rootDir, 'logs'); + +/** Path to the dedicated YouTube OAuth token file (gitignored). */ +export const youtubeOAuthPath = path.join(rootDir, '.youtube-oauth.json'); + +/** + * Loads key-value pairs from .env into process.env without modifying the file. + */ +export function loadEnv() { + const envPath = path.join(rootDir, '.env'); + if (fs.existsSync(envPath)) { + const envContent = fs.readFileSync(envPath, 'utf-8'); + for (const rawLine of envContent.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)$/); + if (match) { + const key = match[1]; + let val = match[2].trim(); + + if (val.startsWith('"')) { + const quoteEnd = val.indexOf('"', 1); + val = quoteEnd !== -1 ? val.substring(1, quoteEnd) : val.substring(1); + } else if (val.startsWith("'")) { + const quoteEnd = val.indexOf("'", 1); + val = quoteEnd !== -1 ? val.substring(1, quoteEnd) : val.substring(1); + } else { + const hashIndex = val.indexOf('#'); + if (hashIndex !== -1) { + val = val.substring(0, hashIndex).trim(); + } + } + + if (!process.env[key]) { + process.env[key] = val; + } + } + } + } +} + +/** + * Extracts a numeric port from an address or URL string. + */ +export function extractPort(urlStr, defaultPort = 3000) { + if (!urlStr) return defaultPort; + try { + const parsed = new URL(urlStr.startsWith('http') ? urlStr : `http://${urlStr}`); + if (parsed.port) return parseInt(parsed.port, 10); + return parsed.protocol === 'https:' ? 443 : 80; + } catch { + const match = urlStr.match(/:(\d+)/); + if (match) return parseInt(match[1], 10); + const directPort = parseInt(urlStr, 10); + if (!isNaN(directPort) && directPort > 0) return directPort; + return defaultPort; + } +} + +/** + * Ensures any processes holding a specified port are terminated. + */ +export function freePort(port) { + if (!port) return; + const isWindows = process.platform === 'win32'; + try { + if (isWindows) { + const stdout = execSync(`netstat -ano | findstr :${port}`, { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'ignore'] + }); + const lines = stdout.split(/\r?\n/); + const pidsToKill = new Set(); + for (const line of lines) { + if (line.includes('LISTENING')) { + const parts = line.trim().split(/\s+/); + const pid = parts[parts.length - 1]; + if (pid && pid !== '0' && /^\d+$/.test(pid)) { + pidsToKill.add(pid); + } + } + } + for (const pid of pidsToKill) { + try { + execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' }); + } catch {} + } + } else { + execSync(`lsof -ti:${port} | xargs kill -9 2>/dev/null || true`, { + stdio: 'ignore' + }); + } + } catch {} +} + +/** + * Kills a process and all of its spawned child processes recursively. + */ +export function killProcessTree(proc) { + if (!proc || !proc.pid) return; + try { + if (process.platform === 'win32') { + execSync(`taskkill /PID ${proc.pid} /T /F`, { stdio: 'ignore' }); + } else { + proc.kill('SIGTERM'); + } + } catch {} +} + +/** + * Checks whether a TCP port is actively open and listening. + */ +export function isPortInUse(port, host = '127.0.0.1', timeoutMs = 1500) { + return new Promise((resolve) => { + import('node:net').then(({ default: net }) => { + const socket = new net.Socket(); + socket.setTimeout(timeoutMs); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => { + socket.destroy(); + resolve(false); + }); + socket.on('timeout', () => { + socket.destroy(); + resolve(false); + }); + socket.connect(port, host); + }); + }); +} + +/** + * Polls a TCP port until a connection succeeds or timeout expires. + */ +export function waitForPort(port, host = '127.0.0.1', timeoutMs = 25000) { + return new Promise((resolve) => { + const start = Date.now(); + const check = () => { + if (Date.now() - start > timeoutMs) { + return resolve(false); + } + import('node:net').then(({ default: net }) => { + const socket = new net.Socket(); + socket.setTimeout(1000); + socket.on('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.on('error', () => { + socket.destroy(); + setTimeout(check, 500); + }); + socket.on('timeout', () => { + socket.destroy(); + setTimeout(check, 500); + }); + socket.connect(port, host); + }); + }; + check(); + }); +} + +/** + * Validates that Java >= 17 is installed and accessible on PATH. + */ +export function checkJavaVersion() { + try { + const output = execSync('java -version 2>&1', { + encoding: 'utf-8', + stdio: 'pipe' + }); + const match = output.match(/version\s+"?(\d+)(?:\.(\d+))?/); + if (!match) { + return { ok: false, error: 'Could not parse Java version output.' }; + } + const major = parseInt(match[1], 10); + const actualMajor = major === 1 ? parseInt(match[2] || '0', 10) : major; + if (actualMajor < 17) { + return { + ok: false, + error: `Java ${actualMajor} detected. Lavalink v4 requires Java 17 or higher (Java 21 LTS recommended).` + }; + } + return { ok: true, version: actualMajor }; + } catch { + return { + ok: false, + error: 'Java not found on PATH. Embedded Lavalink requires Java 17+.' + }; + } +} + +/** + * Loads previously saved YouTube OAuth refresh token from .youtube-oauth.json into process.env. + */ +export function loadYouTubeToken() { + const isLavalinkEnabled = + (process.env.LAVA_ENABLED || 'true').toLowerCase() === 'true'; + if (!isLavalinkEnabled) return; + + const existing = process.env.YOUTUBE_REFRESH_TOKEN?.trim(); + if (existing && existing.startsWith('1/')) return; + + if (!fs.existsSync(youtubeOAuthPath)) return; + + try { + const raw = fs.readFileSync(youtubeOAuthPath, 'utf-8'); + const data = JSON.parse(raw); + if ( + data.refreshToken && + typeof data.refreshToken === 'string' && + data.refreshToken.startsWith('1/') + ) { + process.env.YOUTUBE_REFRESH_TOKEN = data.refreshToken; + console.log( + `\x1b[1;32m✅ [YOUTUBE TOKEN LOADED]\x1b[0m Loaded YouTube OAuth refresh token from .youtube-oauth.json (saved ${data.savedAt || 'unknown date'})\n` + ); + } + } catch {} +} + +export function clearYouTubeRefreshToken() { + delete process.env.YOUTUBE_REFRESH_TOKEN; + try { + if (fs.existsSync(youtubeOAuthPath)) { + fs.unlinkSync(youtubeOAuthPath); + } + } catch {} +} + +export function extractYouTubeRefreshToken(line) { + const match = line.match(/(?:^|[\s:='"(])(1\/[a-zA-Z0-9_\-.~/]+)/); + if (!match) return null; + + const token = match[1].replace(/[}"',.;!)\s]+$/, ''); + if (token.length >= 20 && token.startsWith('1/')) { + return token; + } + return null; +} + +export function saveYouTubeRefreshToken(token) { + if (!token || !token.startsWith('1/')) return; + if (process.env.YOUTUBE_REFRESH_TOKEN === token) return; + + process.env.YOUTUBE_REFRESH_TOKEN = token; + + const data = JSON.stringify( + { refreshToken: token, savedAt: new Date().toISOString() }, + null, + 2 + ); + const tmpPath = youtubeOAuthPath + '.tmp'; + try { + fs.writeFileSync(tmpPath, data, 'utf-8'); + fs.renameSync(tmpPath, youtubeOAuthPath); + } catch { + try { + fs.writeFileSync(youtubeOAuthPath, data, 'utf-8'); + } catch {} + } + + const banner = `\n\x1b[1;32m====================================================================\x1b[0m\n\x1b[1;32m✅ [YOUTUBE REFRESH TOKEN CAPTURED & SAVED]\x1b[0m\n\x1b[1;36m Token:\x1b[0m ${token}\n\x1b[1;32m Persisted to .youtube-oauth.json (survives restart).\x1b[0m\n\x1b[1;32m====================================================================\x1b[0m\n\n`; + process.stdout.write(banner); +} + +export function isAuthInfo(line) { + const lower = line.toLowerCase(); + if ( + lower.includes('exception') || + lower.includes('caused by:') || + lower.includes('unsatisfieddependencyexception') || + lower.includes('beancreationexception') + ) { + return false; + } + + return ( + line.includes('google.com/device') || + line.includes('https://www.google.com/device') || + line.includes('To authenticate') || + (lower.includes('device') && lower.includes('code') && lower.includes('enter')) || + (lower.includes('user_code') && lower.includes('verification_url')) + ); +} + +const prefixColors = { + BOT: '\x1b[1;34m', // blue + 'BOT-ERR': '\x1b[1;31m', // red + DASHBOARD: '\x1b[1;35m', // magenta + 'DASHBOARD-ERR': '\x1b[1;31m', // red + LAVALINK: '\x1b[1;33m', // yellow + 'LAVALINK-ERR': '\x1b[1;31m', // red + SYSTEM: '\x1b[1;36m' // cyan +}; +const RESET = '\x1b[0m'; + +function isErrorLine(line) { + const trimmed = line.trim(); + if (trimmed.startsWith('at ') || trimmed.startsWith('Caused by:')) return false; + if ( + trimmed.includes('error.cause') || + trimmed.includes('errorFormatter') || + trimmed.includes('error_handler') + ) + return false; + + return ( + /\bError\b/.test(trimmed) || + /\bERR\b/.test(trimmed) || + /\bFATAL\b/i.test(trimmed) || + /\bException\b/.test(trimmed) || + /exited with code/i.test(trimmed) || + (/\bfailed\b/i.test(trimmed) && + /\b(to|load|resolve|connect|start|build|compile)\b/i.test(trimmed)) || + /\bcrash/i.test(trimmed) || + /ECONNREFUSED|ENOTFOUND|EACCES|EPERM/i.test(trimmed) + ); +} + +function isWarnLine(line) { + const trimmed = line.trim(); + if (trimmed.startsWith('at ') || trimmed.startsWith('Caused by:')) return false; + return /\bWARN\b/.test(trimmed); +} + +export function createLogWriter(fileStream, combinedStream) { + return function writeLog(prefix, data) { + const timestamp = new Date().toISOString(); + const lines = data.toString().split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) continue; + + const token = extractYouTubeRefreshToken(line); + if (token) { + saveYouTubeRefreshToken(token); + } + + if (line.includes('Invalid status code for oauth2 token fetch: 400')) { + clearYouTubeRefreshToken(); + const errBanner = `\n\x1b[1;31m====================================================================\x1b[0m\n\x1b[1;31m⚠️ [INVALID YOUTUBE REFRESH TOKEN DETECTED]\x1b[0m\n\x1b[1;33m Google rejected the stored YouTube refresh token (HTTP 400 Bad Request).\x1b[0m\n\x1b[1;33m The invalid token has been cleared from .youtube-oauth.json.\x1b[0m\n\x1b[1;36m Lavalink will now prompt for a fresh YouTube device authorization code.\x1b[0m\n\x1b[1;31m====================================================================\x1b[0m\n\n`; + process.stdout.write(errBanner); + } + + if (isAuthInfo(line)) { + const authBanner = `\n\x1b[1;33m====================================================================\x1b[0m\n\x1b[1;32m🔑 [YOUTUBE OAUTH DEVICE AUTHENTICATION REQUIRED]\x1b[0m\n\x1b[1;36m Source:\x1b[0m [${prefix}]\n\x1b[1;37m ${line.trim()}\x1b[0m\n\x1b[1;33m====================================================================\x1b[0m\n\n`; + process.stdout.write(authBanner); + } else { + const entry = `[${timestamp}] [${prefix}] ${line}\n`; + if (fileStream) fileStream.write(entry); + if (combinedStream) combinedStream.write(entry); + + const color = prefixColors[prefix] || '\x1b[1;37m'; + if (isErrorLine(line)) { + process.stderr.write( + `${color}⚠ [${prefix}]${RESET} \x1b[31m${line.trim()}${RESET}\n` + ); + } else if (isWarnLine(line)) { + process.stderr.write( + `${color}⚡ [${prefix}]${RESET} \x1b[33m${line.trim()}${RESET}\n` + ); + } else { + // Forward stdout + process.stdout.write(`${line}\n`); + } + } + } + }; +} diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 000000000..0e0df25a7 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,93 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { + rootDir, + logsDir, + loadEnv, + extractPort, + freePort, + killProcessTree, + checkJavaVersion, + loadYouTubeToken, + createLogWriter +} from './common.mjs'; + +loadEnv(); +loadYouTubeToken(); + +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} + +const isParallel = process.argv.includes('--parallel') || process.env.DEV_PARALLEL === 'true'; + +const port = extractPort(process.env.PORT || process.env.INTERNAL_URL || '3000', 3000); +freePort(port); + +const isLavalinkEnabled = (process.env.LAVA_ENABLED || 'true').toLowerCase() === 'true'; +const isLavaExternal = process.env.LAVA_EXTERNAL === 'true'; + +if (isLavalinkEnabled && !isLavaExternal) { + const javaCheck = checkJavaVersion(); + if (!javaCheck.ok) { + console.warn(`\x1b[1;33m⚠️ [JAVA NOTICE]\x1b[0m ${javaCheck.error}`); + } +} + +const botStream = fs.createWriteStream(path.join(logsDir, 'bot.log'), { flags: 'w' }); +const dashboardStream = fs.createWriteStream(path.join(logsDir, 'dashboard.log'), { flags: 'w' }); +const combinedStream = fs.createWriteStream(path.join(logsDir, 'combined.log'), { flags: 'w' }); + +const writeBotLog = createLogWriter(botStream, combinedStream); +const writeDashboardLog = createLogWriter(dashboardStream, combinedStream); + +let botProcess = null; +let dashboardProcess = null; + +if (isParallel) { + process.env.DISABLE_INTERNAL_DASHBOARD = 'true'; + + botProcess = spawn('pnpm --filter @master-bot/bot dev', { + cwd: rootDir, + shell: true, + env: { ...process.env, NODE_ENV: 'development' } + }); + botProcess.stdout?.on('data', (data) => writeBotLog('BOT', data)); + botProcess.stderr?.on('data', (data) => writeBotLog('BOT-ERR', data)); + + dashboardProcess = spawn('pnpm --filter @master-bot/dashboard dev', { + cwd: rootDir, + shell: true, + env: { ...process.env, NODE_ENV: 'development' } + }); + dashboardProcess.stdout?.on('data', (data) => writeDashboardLog('DASHBOARD', data)); + dashboardProcess.stderr?.on('data', (data) => writeDashboardLog('DASHBOARD-ERR', data)); +} else { + botProcess = spawn('pnpm --filter @master-bot/bot dev', { + cwd: rootDir, + shell: true, + env: { ...process.env, NODE_ENV: 'development' } + }); + botProcess.stdout?.on('data', (data) => writeBotLog('BOT', data)); + botProcess.stderr?.on('data', (data) => writeBotLog('BOT-ERR', data)); +} + +function cleanup() { + console.log('\n🛑 Shutting down Master-Bot development services...'); + try { + if (botProcess) killProcessTree(botProcess); + if (dashboardProcess) killProcessTree(dashboardProcess); + } catch {} + try { + botStream.end(); + dashboardStream.end(); + combinedStream.end(); + } catch {} + process.exit(0); +} + +process.on('SIGINT', cleanup); +process.on('SIGTERM', cleanup); +process.on('SIGHUP', cleanup); +process.on('exit', cleanup); diff --git a/scripts/start.mjs b/scripts/start.mjs new file mode 100644 index 000000000..f1f7223a9 --- /dev/null +++ b/scripts/start.mjs @@ -0,0 +1,87 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { + rootDir, + logsDir, + loadEnv, + extractPort, + freePort, + killProcessTree, + checkJavaVersion, + loadYouTubeToken, + createLogWriter +} from './common.mjs'; + +loadEnv(); +loadYouTubeToken(); + +if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); +} + +const port = extractPort(process.env.PORT || process.env.INTERNAL_URL || '3000', 3000); +freePort(port); + +const isLavalinkEnabled = (process.env.LAVA_ENABLED || 'true').toLowerCase() === 'true'; +const isLavaExternal = process.env.LAVA_EXTERNAL === 'true'; + +if (isLavalinkEnabled && !isLavaExternal) { + const javaCheck = checkJavaVersion(); + if (!javaCheck.ok) { + console.warn(`\x1b[1;33m⚠️ [JAVA NOTICE]\x1b[0m ${javaCheck.error}`); + } +} + +const botStream = fs.createWriteStream(path.join(logsDir, 'bot.log'), { flags: 'w' }); +const combinedStream = fs.createWriteStream(path.join(logsDir, 'combined.log'), { flags: 'w' }); + +const writeBotLog = createLogWriter(botStream, combinedStream); + +console.log( + '\x1b[1;36m\n' + + '╔══════════════════════════════════════════════════════════════╗\n' + + '║ 🤖 Master-Bot — PRODUCTION MODE ║\n' + + '╚══════════════════════════════════════════════════════════════╝\x1b[0m\n' + + `\x1b[90m Logs → ${logsDir}\x1b[0m\n` +); + +let botProcess = null; + +botProcess = spawn('pnpm --filter @master-bot/bot start', { + cwd: rootDir, + shell: true, + env: { ...process.env, NODE_ENV: 'production' } +}); + +botProcess.stdout?.on('data', (data) => writeBotLog('BOT', data)); +botProcess.stderr?.on('data', (data) => writeBotLog('BOT-ERR', data)); + +botProcess.on('exit', (code, signal) => { + if (code !== 0 && signal !== 'SIGTERM' && signal !== 'SIGINT') { + console.error( + `\x1b[1;31m✗ [BOT] Process exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'none'})\x1b[0m` + ); + } + cleanup(false); +}); + +let cleaningUp = false; +function cleanup(shouldExit = true) { + if (cleaningUp) return; + cleaningUp = true; + + console.log('\n\x1b[1;33m🛑 Shutting down Master-Bot production services...\x1b[0m'); + try { + if (botProcess) killProcessTree(botProcess); + } catch {} + try { + botStream.end(); + combinedStream.end(); + } catch {} + if (shouldExit) process.exit(0); +} + +process.on('SIGINT', () => cleanup(true)); +process.on('SIGTERM', () => cleanup(true)); +process.on('SIGHUP', () => cleanup(true)); diff --git a/tests/unit/databaseFallback.test.ts b/tests/unit/databaseFallback.test.ts new file mode 100644 index 000000000..05359655d --- /dev/null +++ b/tests/unit/databaseFallback.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { getDatabaseProvider, isUsingMockRedis, getRedisClient, redis } from '@master-bot/db'; + +describe('Dual Database & Redis Fallback Architecture', () => { + const originalDbUri = process.env.DB_URI; + const originalRedisUrl = process.env.REDIS_URL; + const originalRedisHost = process.env.REDIS_HOST; + + afterEach(() => { + process.env.DB_URI = originalDbUri; + process.env.REDIS_URL = originalRedisUrl; + process.env.REDIS_HOST = originalRedisHost; + }); + + describe('Database Provider Reflection', () => { + it('should detect SQLite provider when DB_URI is file:/data/db.sqlite', () => { + process.env.DB_URI = 'file:/data/db.sqlite'; + expect(getDatabaseProvider()).toBe('sqlite'); + }); + + it('should detect SQLite provider when DB_URI is unset or empty', () => { + delete process.env.DB_URI; + expect(getDatabaseProvider()).toBe('sqlite'); + }); + + it('should detect PostgreSQL provider when DB_URI starts with postgresql://', () => { + process.env.DB_URI = 'postgresql://postgres:secret@localhost:5432/masterbot'; + expect(getDatabaseProvider()).toBe('postgresql'); + }); + + it('should detect PostgreSQL provider when DB_URI starts with postgres://', () => { + process.env.DB_URI = 'postgres://user:password@host:5432/dbname'; + expect(getDatabaseProvider()).toBe('postgresql'); + }); + }); + + describe('Redis In-Memory Fallback', () => { + it('should identify active client as in-memory fallback when no external server is present', () => { + expect(isUsingMockRedis()).toBe(true); + }); + + it('should perform all common cache operations against the fallback client', async () => { + const client = getRedisClient(); + await client.set('fallback:key', 'active-fallback-value'); + const value = await client.get('fallback:key'); + expect(value).toBe('active-fallback-value'); + await client.del('fallback:key'); + }); + + it('should handle JSON serialization for guild settings in fallback mode', async () => { + const payload = { + id: '999888777', + name: 'Fallback Guild', + volume: 85 + }; + + await redis.hset('guilds', payload.id, JSON.stringify(payload)); + const raw = await redis.hget('guilds', payload.id); + expect(raw).toBeDefined(); + + const parsed = JSON.parse(raw!); + expect(parsed.name).toBe('Fallback Guild'); + expect(parsed.volume).toBe(85); + + await redis.hdel('guilds', payload.id); + }); + }); +}); diff --git a/tests/unit/embedHandler.test.ts b/tests/unit/embedHandler.test.ts new file mode 100644 index 000000000..9dfa9a98d --- /dev/null +++ b/tests/unit/embedHandler.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from 'vitest'; +import { EmbedHandler } from '../../apps/bot/src/lib/embeds/embedHandler'; +import { EmbedColors } from '../../apps/bot/src/lib/embeds/types'; + +describe('EmbedHandler Format & Layout Engine', () => { + it('should resolve standard and custom colors accurately', () => { + expect(EmbedHandler.resolveColor('brand')).toBe(EmbedColors.Brand); + expect(EmbedHandler.resolveColor('success')).toBe(EmbedColors.Success); + expect(EmbedHandler.resolveColor('error')).toBe(EmbedColors.Error); + expect(EmbedHandler.resolveColor('warning')).toBe(EmbedColors.Warning); + expect(EmbedHandler.resolveColor('info')).toBe(EmbedColors.Info); + expect(EmbedHandler.resolveColor('music')).toBe(EmbedColors.Music); + expect(EmbedHandler.resolveColor('twitch')).toBe(EmbedColors.Twitch); + expect(EmbedHandler.resolveColor('dark')).toBe(EmbedColors.Dark); + expect(EmbedHandler.resolveColor('gold')).toBe(EmbedColors.Gold); + expect(EmbedHandler.resolveColor(undefined, 0x123456)).toBe(0x123456); + }); + + it('should create a clean standard embed with title, description, and footer', () => { + const embed = EmbedHandler.create({ + title: 'Master-Bot Status', + description: 'Operational status report', + variant: 'brand', + footer: 'System Monitoring' + }); + + const json = embed.toJSON(); + expect(json.title).toBe('Master-Bot Status'); + expect(json.description).toBe('Operational status report'); + expect(json.color).toBe(EmbedColors.Brand); + expect(json.footer?.text).toBe('System Monitoring'); + expect(json.timestamp).toBeDefined(); + }); + + it('should format semantic embeds with appropriate emoji prefixes and colors', () => { + const success = EmbedHandler.success({ + title: 'Operation Complete', + description: 'Changes applied successfully.' + }); + expect(success.toJSON().title).toBe('✅ Operation Complete'); + expect(success.toJSON().color).toBe(EmbedColors.Success); + + const error = EmbedHandler.error({ + title: 'Access Denied', + description: 'Missing permissions.' + }); + expect(error.toJSON().title).toBe('❌ Access Denied'); + expect(error.toJSON().color).toBe(EmbedColors.Error); + + const warning = EmbedHandler.warning({ + title: 'High Latency', + description: 'WebSocket ping exceeds 250ms.' + }); + expect(warning.toJSON().title).toBe('⚠️ High Latency'); + expect(warning.toJSON().color).toBe(EmbedColors.Warning); + + const info = EmbedHandler.info({ + title: 'New Update', + description: 'Master-Bot v1.0 is available.' + }); + expect(info.toJSON().title).toBe('ℹ️ New Update'); + expect(info.toJSON().color).toBe(EmbedColors.Info); + + const music = EmbedHandler.music({ + title: 'Now Playing', + description: 'Track details.' + }); + expect(music.toJSON().title).toBe('🎵 Now Playing'); + expect(music.toJSON().color).toBe(EmbedColors.Music); + }); + + it('should construct structured card embeds with category badges', () => { + const card = EmbedHandler.card({ + category: 'Audit Log', + title: 'Member Banned', + badge: 'SECURITY', + description: 'Target: User#0001' + }); + + const json = card.toJSON(); + expect(json.title).toBe('[SECURITY] Audit Log • Member Banned'); + expect(json.color).toBe(EmbedColors.Dark); + }); + + it('should format fields cleanly and handle null/empty values with safe fallbacks', () => { + const f1 = EmbedHandler.field('👑 Guild Owner', 'Nir#0001', true); + expect(f1).toEqual({ + name: '👑 Guild Owner', + value: 'Nir#0001', + inline: true + }); + + const f2 = EmbedHandler.field('📝 Description', null, false); + expect(f2).toEqual({ + name: '📝 Description', + value: '*None*', + inline: false + }); + + const f3 = EmbedHandler.field('🔢 Count', 0, true); + expect(f3).toEqual({ + name: '🔢 Count', + value: '0', + inline: true + }); + }); + + it('should generate inline field grids from key-value records', () => { + const fields = EmbedHandler.fieldsFromRecord({ + '👥 Members': 1420, + '📁 Channels': 32, + '🚀 Boosts': 'Level 3' + }); + + expect(fields).toHaveLength(3); + expect(fields[0]).toEqual({ + name: '👥 Members', + value: '1420', + inline: true + }); + expect(fields[1]).toEqual({ + name: '📁 Channels', + value: '32', + inline: true + }); + }); + + it('should enforce Discord length constraints with safe truncation', () => { + const longTitle = 'A'.repeat(300); + const longDesc = 'B'.repeat(5000); + const embed = EmbedHandler.create({ + title: longTitle, + description: longDesc + }); + + const json = embed.toJSON(); + expect(json.title?.length).toBe(256); + expect(json.title?.endsWith('...')).toBe(true); + expect(json.description?.length).toBe(4096); + expect(json.description?.endsWith('...')).toBe(true); + }); + + it('should provide Discord markdown and epoch timestamp formatting utilities', () => { + expect(EmbedHandler.code('pnpm dev')).toBe('`pnpm dev`'); + expect(EmbedHandler.codeBlock('console.log(1);', 'typescript')).toBe( + '```typescript\nconsole.log(1);\n```' + ); + expect(EmbedHandler.quote('Important notice')).toBe('> Important notice'); + expect(EmbedHandler.list(['Item 1', 'Item 2'])).toBe('• Item 1\n• Item 2'); + + const testDate = new Date(1700000000000); + expect(EmbedHandler.timestamp(testDate, 'R')).toBe('<t:1700000000:R>'); + expect(EmbedHandler.timestamp(testDate, 'F')).toBe('<t:1700000000:F>'); + }); +}); diff --git a/tests/unit/gifCommand.test.ts b/tests/unit/gifCommand.test.ts new file mode 100644 index 000000000..b0a235af4 --- /dev/null +++ b/tests/unit/gifCommand.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest'; +import { + getGifTag, + getAllGifTags, + getRandomGifTag, + getGifChoices +} from '../../apps/bot/src/lib/gifs/options/registry'; +import type { User } from 'discord.js'; + +describe('GIF Submodule & Tag Registry', () => { + it('should register all 11 modular gif tags', () => { + const tags = getAllGifTags(); + expect(tags).toHaveLength(11); + const tagNames = tags.map(t => t.name); + expect(tagNames).toEqual( + expect.arrayContaining([ + 'amongus', + 'anime', + 'baka', + 'cat', + 'doggo', + 'gintama', + 'hug', + 'jojo', + 'pat', + 'slap', + 'waifu' + ]) + ); + }); + + it('should lookup specific tags case-insensitively', () => { + const hug = getGifTag('HUG'); + expect(hug).toBeDefined(); + expect(hug?.name).toBe('hug'); + expect(hug?.targetSupported).toBe(true); + + const cat = getGifTag('cat'); + expect(cat).toBeDefined(); + expect(cat?.searchKeyword).toBe('cat'); + }); + + it('should format action text for targeted reactions correctly', () => { + const hug = getGifTag('hug')!; + const mockUser = { id: 'user-1', toString: () => '<@user-1>' } as User; + const mockTarget = { id: 'user-2', toString: () => '<@user-2>' } as User; + + // Targeted at another user + const otherAction = hug.formatAction!(mockUser, mockTarget); + expect(otherAction).toContain('<@user-2>'); + expect(otherAction).toContain('warm hug'); + + // Self target + const selfAction = hug.formatAction!(mockUser, mockUser); + expect(selfAction).toContain('gives themselves a warm hug'); + }); + + it('should pick a random tag from the registered collection', () => { + const random = getRandomGifTag(); + expect(random).toBeDefined(); + expect(random.name).toBeDefined(); + expect(random.searchKeyword).toBeDefined(); + }); + + it('should generate slash command choices for all tags', () => { + const choices = getGifChoices(); + expect(choices).toHaveLength(11); + expect(choices[0]).toHaveProperty('name'); + expect(choices[0]).toHaveProperty('value'); + }); +}); diff --git a/tests/unit/keepAlive.test.ts b/tests/unit/keepAlive.test.ts new file mode 100644 index 000000000..84548d5b5 --- /dev/null +++ b/tests/unit/keepAlive.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const mockGet = vi.fn(); +vi.mock('axios', () => ({ + default: { + get: mockGet + } +})); + +import { startKeepAlive, stopKeepAlive } from '../../apps/bot/src/lib/server/keepAlive'; + +describe('Keep-Alive Service', () => { + const originalEnv = { ...process.env }; + + beforeEach(() => { + process.env = { ...originalEnv }; + mockGet.mockReset(); + stopKeepAlive(); + }); + + afterEach(() => { + stopKeepAlive(); + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + it('should respect KEEP_ALIVE_ENABLED=false and not start', () => { + process.env.KEEP_ALIVE_ENABLED = 'false'; + + startKeepAlive(3000); + expect(mockGet).not.toHaveBeenCalled(); + }); + + it('should resolve RENDER_EXTERNAL_URL when provided by Render', () => { + process.env.RENDER_EXTERNAL_URL = 'https://master-bot-test.onrender.com'; + process.env.KEEP_ALIVE_INTERVAL_MS = '500'; + + mockGet.mockResolvedValue({ status: 200 } as any); + + startKeepAlive(10000); + expect(startKeepAlive).toBeDefined(); + }); + + it('should gracefully handle ping failures without throwing unhandled exceptions', () => { + process.env.KEEP_ALIVE_URL = 'http://127.0.0.1:9999'; + mockGet.mockRejectedValue(new Error('Connection refused')); + + expect(() => { + startKeepAlive(9999); + }).not.toThrow(); + }); +}); diff --git a/tests/unit/redis.test.ts b/tests/unit/redis.test.ts new file mode 100644 index 000000000..6d7c1488f --- /dev/null +++ b/tests/unit/redis.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { getRedisClient, redis } from '../../packages/db/index'; + +describe('Zero-Binary In-Memory Redis (ioredis-mock)', () => { + beforeEach(async () => { + await redis.flushall(); + }); + + it('should initialize and connect without an external Redis server binary', async () => { + expect(redis).toBeDefined(); + const client = getRedisClient(); + expect(client).toBe(redis); // verify singleton + }); + + it('should perform basic key-value operations in memory', async () => { + await redis.set('test:key', 'hello-world'); + const val = await redis.get('test:key'); + expect(val).toBe('hello-world'); + + await redis.del('test:key'); + const missing = await redis.get('test:key'); + expect(missing).toBeNull(); + }); + + it('should accurately handle guild hashes used by the bot and dashboard', async () => { + const guildData = { + id: '123456789012345678', + name: 'Test Server', + icon: null + }; + + // Bot syncs guild to Redis + await redis.hset('guilds', guildData.id, JSON.stringify(guildData)); + + // Dashboard reads single guild + const singleRaw = await redis.hget('guilds', guildData.id); + expect(singleRaw).toBeDefined(); + const singleParsed = JSON.parse(singleRaw!); + expect(singleParsed.name).toBe('Test Server'); + + // Dashboard reads all guilds + const allGuilds = await redis.hgetall('guilds'); + expect(allGuilds[guildData.id]).toBeDefined(); + + // Deletion + await redis.hdel('guilds', guildData.id); + const afterDel = await redis.hget('guilds', guildData.id); + expect(afterDel).toBeNull(); + }); + + it('should support defineCommand for custom Lua audio commands', async () => { + expect(typeof (redis as any).defineCommand).toBe('function'); + + (redis as any).defineCommand('testMultiply', { + numberOfKeys: 1, + lua: 'return redis.call("GET", KEYS[1]) * ARGV[1]' + }); + + await redis.set('math:num', 5); + const result = await (redis as any).testMultiply('math:num', 10); + expect(Number(result)).toBe(50); + }); + + it('should share memory state across multiple getRedisClient calls', async () => { + const clientA = getRedisClient(); + const clientB = getRedisClient(); + + await clientA.set('shared:test', 'sync-value'); + const valueFromB = await clientB.get('shared:test'); + expect(valueFromB).toBe('sync-value'); + }); + + it('should strictly use in-memory mock without attempting external connection even if REDIS_URL is present', async () => { + const originalUrl = process.env.REDIS_URL; + const originalHost = process.env.REDIS_HOST; + try { + process.env.REDIS_URL = 'redis://invalid-host-that-does-not-exist:9999'; + process.env.REDIS_HOST = 'invalid-host-that-does-not-exist'; + + const client = getRedisClient(); + // Setting and getting should still succeed instantaneously in memory without network timeout or error + await client.set('env:test', 'isolated-in-memory'); + const val = await client.get('env:test'); + expect(val).toBe('isolated-in-memory'); + } finally { + process.env.REDIS_URL = originalUrl; + process.env.REDIS_HOST = originalHost; + } + }); +}); + diff --git a/tests/unit/setCommand.test.ts b/tests/unit/setCommand.test.ts new file mode 100644 index 000000000..8b2e073a5 --- /dev/null +++ b/tests/unit/setCommand.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { + getSetOption, + getAllSetOptions, + getSetChoices +} from '../../apps/bot/src/lib/set/options/registry'; + +describe('Set Command Modular Option Registry', () => { + it('should register all 22 configuration options', () => { + const options = getAllSetOptions(); + expect(options.length).toBe(22); + + const names = options.map(o => o.name); + expect(names).toContain('view'); + expect(names).toContain('welcome-channel'); + expect(names).toContain('welcome-message'); + expect(names).toContain('welcome-toggle'); + expect(names).toContain('welcome-test'); + expect(names).toContain('twitch-add'); + expect(names).toContain('twitch-remove'); + expect(names).toContain('twitch-list'); + expect(names).toContain('youtube-add'); + expect(names).toContain('youtube-remove'); + expect(names).toContain('youtube-list'); + expect(names).toContain('log-channel'); + expect(names).toContain('log-toggle'); + expect(names).toContain('log-disable'); + expect(names).toContain('ticket-channel'); + expect(names).toContain('ticket-toggle'); + expect(names).toContain('ticket-panel'); + expect(names).toContain('ticket-transcript'); + expect(names).toContain('ticket-transcript-disable'); + expect(names).toContain('ticket-role'); + expect(names).toContain('ticket-role-disable'); + expect(names).toContain('default-volume'); + }); + + it('should retrieve options case-insensitively with executable handlers', () => { + const yt = getSetOption('YOUTUBE-ADD'); + expect(yt).toBeDefined(); + expect(yt?.name).toBe('youtube-add'); + expect(typeof yt?.execute).toBe('function'); + + const view = getSetOption('view'); + expect(view).toBeDefined(); + expect(typeof view?.execute).toBe('function'); + }); + + it('should provide Discord-compliant choices under 100 characters', () => { + const choices = getSetChoices(); + expect(choices.length).toBe(22); + for (const choice of choices) { + expect(choice.name.length).toBeLessThanOrEqual(100); + expect(choice.value.length).toBeLessThanOrEqual(100); + } + }); +}); diff --git a/tests/unit/trpcContext.test.ts b/tests/unit/trpcContext.test.ts new file mode 100644 index 000000000..1666d56a1 --- /dev/null +++ b/tests/unit/trpcContext.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; +import { prisma, redis } from '../../packages/db/index'; + +describe('TRPC Context & Data Layer Bindings', () => { + it('should provide active Prisma client connected to SQLite database', () => { + expect(prisma).toBeDefined(); + expect(typeof prisma.$connect).toBe('function'); + }); + + it('should provide shared Redis client with in-memory store', async () => { + expect(redis).toBeDefined(); + await redis.set('context:check', 'active'); + const result = await redis.get('context:check'); + expect(result).toBe('active'); + }); + + it('should maintain data persistence across simulated restarts via SQLite', async () => { + // Verify schema model exists for persistent guild configuration + expect(prisma.guild).toBeDefined(); + expect(typeof prisma.guild.findMany).toBe('function'); + expect(typeof prisma.guild.upsert).toBe('function'); + }); +}); + diff --git a/tests/unit/webServer.test.ts b/tests/unit/webServer.test.ts new file mode 100644 index 000000000..f299cdd15 --- /dev/null +++ b/tests/unit/webServer.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; + +describe('Internal Web Server Health & Keep-Alive Endpoints', () => { + let server: http.Server; + const testPort = 3987; + + beforeAll(async () => { + // Create a server reproducing the exact routing logic in webServer.ts + server = http.createServer((req, res) => { + const url = req.url || ''; + if (url === '/health' || url === '/api/health' || url === '/keep-alive') { + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store' + }); + res.end( + JSON.stringify({ + status: 'ok', + service: 'Master-Bot', + uptime: Math.floor(process.uptime()), + timestamp: Date.now(), + botReady: true + }) + ); + return; + } + res.writeHead(404); + res.end('Not Found'); + }); + + await new Promise<void>(resolve => server.listen(testPort, '127.0.0.1', () => resolve())); + }); + + afterAll(async () => { + await new Promise<void>(resolve => server.close(() => resolve())); + }); + + it('should return 200 OK with service details on /health', async () => { + const res = await fetch(`http://127.0.0.1:${testPort}/health`); + expect(res.status).toBe(200); + const data = (await res.json()) as any; + expect(data.status).toBe('ok'); + expect(data.service).toBe('Master-Bot'); + expect(data.botReady).toBe(true); + expect(typeof data.uptime).toBe('number'); + }); + + it('should return 200 OK on /api/health alias', async () => { + const res = await fetch(`http://127.0.0.1:${testPort}/api/health`); + expect(res.status).toBe(200); + const data = (await res.json()) as any; + expect(data.status).toBe('ok'); + }); + + it('should return 200 OK on /keep-alive alias', async () => { + const res = await fetch(`http://127.0.0.1:${testPort}/keep-alive`); + expect(res.status).toBe(200); + const data = (await res.json()) as any; + expect(data.status).toBe('ok'); + }); +}); diff --git a/tests/unit/youtubeAlerts.test.ts b/tests/unit/youtubeAlerts.test.ts new file mode 100644 index 000000000..7c07f61ab --- /dev/null +++ b/tests/unit/youtubeAlerts.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + createYouTubeUploadEmbed, + createYouTubeLiveEmbed +} from '../../apps/bot/src/lib/embeds/commands/other/youtubeEmbed'; +import { createYouTubeConfigHandlers } from '../../apps/bot/src/lib/session/handlers/youtubeConfig'; +import { SessionStore } from '../../apps/bot/src/lib/session/SessionStore'; +import { EmbedColors } from '../../apps/bot/src/lib/embeds/types'; + +describe('YouTube Alert Embeds & Session Management', () => { + describe('Embed Creation', () => { + it('should create an upload notification embed with video metadata', () => { + const embed = createYouTubeUploadEmbed({ + channelTitle: 'Tech Channel', + videoTitle: 'Building Scalable Bots', + videoUrl: 'https://www.youtube.com/watch?v=vid12345', + videoId: 'vid12345', + thumbnail: 'https://i.ytimg.com/vi/vid12345/hqdefault.jpg', + publishedAt: '2026-09-16T12:00:00Z' + }); + + const json = embed.toJSON(); + expect(json.title).toBe('🎬 New Video: Building Scalable Bots'); + expect(json.url).toBe('https://www.youtube.com/watch?v=vid12345'); + expect(json.author?.name).toBe('Tech Channel'); + expect(json.color).toBe(0xff0000); // YouTube brand red + expect(json.image?.url).toBe( + 'https://img.youtube.com/vi/vid12345/maxresdefault.jpg' + ); + expect(json.footer?.text).toContain('YouTube Upload Alert'); + }); + + it('should create a live stream notification embed with live indicator', () => { + const embed = createYouTubeLiveEmbed({ + channelTitle: 'Gamer Channel', + channelUrl: 'https://www.youtube.com/@GamerChannel', + streamTitle: 'Speedrunning Championship', + streamUrl: 'https://www.youtube.com/watch?v=live99999', + videoId: 'live99999', + thumbnailUrl: 'https://i.ytimg.com/vi/live99999/hqdefault.jpg' + }); + + const json = embed.toJSON(); + expect(json.title).toBe('🔴 LIVE NOW: Speedrunning Championship'); + expect(json.url).toBe('https://www.youtube.com/watch?v=live99999'); + expect(json.author?.name).toBe('Gamer Channel is Live!'); + expect(json.color).toBe(16711731); // 0xFF0033 Live red + expect(json.fields?.some((f) => f.name === '📺 Channel')).toBe(true); + expect(json.footer?.text).toContain('YouTube Live Alert'); + }); + }); + + describe('YouTube Session Configuration', () => { + let store: SessionStore; + let config: ReturnType<typeof createYouTubeConfigHandlers>; + + beforeEach(() => { + process.env.DB_URI = process.env.DB_URI || 'file:/data/db.sqlite'; + store = new SessionStore(); + config = createYouTubeConfigHandlers(store); + }); + + it('should add a new YouTube subscription with target channel', () => { + const result = config.addSubscription({ + channelId: 'UC1234567890', + channelTitle: 'Coding Hub', + discordChannelId: 'discord-chan-01', + alertType: 'all' + }); + + expect(result.channelId).toBe('UC1234567890'); + expect(result.channelTitle).toBe('Coding Hub'); + expect(result.channelIds).toHaveLength(1); + expect(result.channelIds[0]).toEqual({ + channelId: 'discord-chan-01', + alertType: 'all' + }); + + const sub = config.getSubscription('UC1234567890'); + expect(sub).toBeDefined(); + expect(sub?.channelTitle).toBe('Coding Hub'); + }); + + it('should append a target channel to an existing YouTube subscription', () => { + config.addSubscription({ + channelId: 'UC1234567890', + channelTitle: 'Coding Hub', + discordChannelId: 'discord-chan-01', + alertType: 'streams' + }); + + const updated = config.addSubscription({ + channelId: 'UC1234567890', + channelTitle: 'Coding Hub', + discordChannelId: 'discord-chan-02', + alertType: 'uploads' + }); + + expect(updated.channelIds).toHaveLength(2); + expect(updated.channelIds).toEqual([ + { channelId: 'discord-chan-01', alertType: 'streams' }, + { channelId: 'discord-chan-02', alertType: 'uploads' } + ]); + }); + + it('should remove target channel and cleanup subscription when no targets remain', () => { + config.addSubscription({ + channelId: 'UC1234567890', + channelTitle: 'Coding Hub', + discordChannelId: 'discord-chan-01', + alertType: 'all' + }); + + const result = config.removeSubscription({ + channelId: 'UC1234567890', + discordChannelId: 'discord-chan-01' + }); + expect(result.success).toBe(true); + expect(result.remainingCount).toBe(0); + + const sub = config.getSubscription('UC1234567890'); + expect(sub).toBeFalsy(); + }); + + it('should update live status and video tracking IDs', () => { + config.addSubscription({ + channelId: 'UC1234567890', + channelTitle: 'Coding Hub', + discordChannelId: 'discord-chan-01', + alertType: 'all' + }); + + config.updateStatus({ + channelId: 'UC1234567890', + isLive: true, + lastStreamId: 'stream-xyz', + lastVideoId: 'video-abc' + }); + + const sub = config.getSubscription('UC1234567890'); + expect(sub?.isLive).toBe(true); + expect(sub?.lastStreamId).toBe('stream-xyz'); + expect(sub?.lastVideoId).toBe('video-abc'); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 6d6afdee8..129bb6e46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", diff --git a/turbo.json b/turbo.json index 920452d34..cc5418bb3 100644 --- a/turbo.json +++ b/turbo.json @@ -33,16 +33,46 @@ }, "globalEnv": [ "CI", - "DATABASE_URL", + "DB_URI", "DISCORD_TOKEN", "DISCORD_CLIENT_ID", "DISCORD_CLIENT_SECRET", + "DISCORD_OWNER_ID", + "OWNER_ID", "NEXT_PUBLIC_INVITE_URL", + "DISCORD_CALLBACK_URL", + "PUBLIC_URL", + "INTERNAL_URL", "NEXTAUTH_SECRET", "NEXTAUTH_URL", + "NEXTAUTH_URL_INTERNAL", "NODE_ENV", "SKIP_ENV_VALIDATION", "VERCEL", - "VERCEL_URL" + "VERCEL_URL", + "LAVA_HOST", + "LAVA_PASS", + "LAVA_PORT", + "LAVA_SECURE", + "LAVA_EXTERNAL", + "LAVA_ENABLED", + "GIFS_ENABLED", + "TWITCH_ENABLED", + "NEWS_ENABLED", + "IGDB_ENABLED", + "YOUTUBE_CLIENT_ID", + "YOUTUBE_CLIENT_SECRET", + "YOUTUBE_REFRESH_TOKEN", + "YOUTUBE_CIPHER_URL", + "YOUTUBE_CIPHER_PASSWORD", + "SPOTIFY_CLIENT_ID", + "SPOTIFY_CLIENT_SECRET", + "TWITCH_CLIENT_ID", + "TWITCH_CLIENT_SECRET", + "KLIPY_API", + "NEWS_API", + "GENIUS_API", + "DASHBOARD_THEME", + "DASHBOARD_COLOR_SCHEME" ] } diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 000000000..d1a4211ed --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,21 @@ +import { defineMonorepoConfig } from '@helix-origin/vitest-suite/presets'; +import path from 'node:path'; + +export default defineMonorepoConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + alias: { + '@master-bot/db': path.resolve(__dirname, 'packages/db/index.ts'), + '~': path.resolve(__dirname, 'apps/dashboard/src') + }, + pool: 'forks', + poolOptions: { + forks: { + singleFork: true + } + }, + testTimeout: 15000 + } +}); diff --git a/wiki/Architecture.md b/wiki/Architecture.md new file mode 100644 index 000000000..fcafa7d88 --- /dev/null +++ b/wiki/Architecture.md @@ -0,0 +1,220 @@ +# 🏗️ Architecture & System Topology + +Master-Bot is engineered as a unified **Turborepo** monorepo running both the **Discord Bot Gateway** and the **Next.js Web Dashboard** inside a single Node.js process, supported by a dual-database persistence tier (`DB_URI`) and an in-memory/external caching layer. + +--- + +## 📑 Table of Contents +1. [Monorepo Workspace Structure](#-monorepo-workspace-structure) +2. [Package Responsibilities](#-package-responsibilities) +3. [Single-Process Unified Runtime](#-single-process-unified-runtime) +4. [Session Layer & State Management](#-session-layer--state-management) +5. [🗄️ Database Tier (`DB_URI`) & Schema](#️-database-tier-db_uri--schema) +6. [Command & Submodule Architecture](#-command--submodule-architecture) +7. [Bootstrap & Lifecycle Sequence](#-bootstrap--lifecycle-sequence) +8. [Related Guides](#-related-guides) + +--- + +## 📁 Monorepo Workspace Structure + +```text +Master-Bot/ +├── apps/ +│ ├── bot/ # Discord bot application (Sapphire Framework & discord.js v14) +│ │ └── src/ +│ │ ├── index.ts # Unified process boot: session → login → web server +│ │ ├── commands/ # Slash commands categorized (fun, moderation, music, other) +│ │ ├── listeners/ # Gateway event listeners (guild, interaction, music, tickets) +│ │ └── lib/ +│ │ ├── embeds/ # Standardized rich embed factory +│ │ ├── gifs/ # Consolidated /gif option handlers & tag registry +│ │ ├── set/ # Consolidated /set option handlers & settings registry +│ │ ├── lavalink/ # Embedded Lavalink v4 integration (@helix-origin/lavalink-server) +│ │ ├── music/ # Audio queue management, filters, and playlists +│ │ ├── youtube/ # YouTube OAuth, RSS parser, and stream monitor +│ │ ├── twitch/ # Twitch API client and stream monitor +│ │ ├── reminders/ # ReminderManager background scheduler +│ │ ├── server/ # Internal HTTP web server & health pinger +│ │ └── session/ # SessionManager, SessionStore, and state handlers +│ └── dashboard/ # Next.js 15 App Router & tRPC v11 web dashboard +├── packages/ +│ ├── auth/ # NextAuth.js v5 with Discord OAuth provider & Prisma adapter +│ ├── config/ # Shared ESLint and Tailwind presets +│ └── db/ # Shared Prisma ORM client & ioredis-mock cache fallback +├── tests/unit/ # Comprehensive Vitest test suite (@helix-origin/vitest-suite) +├── application.yml.example # Lavalink v4 configuration blueprint +├── Dockerfile & docker.env # Containerized production blueprint +└── .env.example # Master environment variable template +``` + +--- + +## 📦 Package Responsibilities + +| Package | Technology Stack | Responsibility | +| :--- | :--- | :--- | +| `apps/bot` | Sapphire 4.x, discord.js v14 | Discord gateway, slash commands, audio routing, background monitors. | +| `apps/dashboard` | Next.js 15, React 18, Tailwind, tRPC v11 | Responsive web management studios for server configuration. | +| `packages/db` | Prisma ORM 5.x | Database connection, schema compilation, and client distribution. | +| `packages/auth` | NextAuth.js, Discord OAuth | Secure web session tokens and user identity linking. | +| `packages/config` | ESLint, TypeScript, Tailwind | Unified monorepo linting, typing, and style configurations. | + +--- + +## ⚡ Single-Process Unified Runtime + +Master-Bot avoids fragile multi-process architectures by hosting both the Discord bot and the Next.js web application inside one single Node.js process: + +```mermaid +flowchart TD + subgraph HostSystem [Master-Bot Service :3000] + Entry[apps/bot/src/index.ts] + BotGateway[Sapphire Discord Gateway] + WebServer[Internal HTTP Web Server] + NextApp[Next.js 15 App Router Engine] + LavalinkInternal[Embedded Lavalink Server v4] + Session[In-Memory SessionManager] + Cache[ioredis-mock In-Memory Cache] + end + + subgraph DataPersistence [Persistence Tier] + DB[(Prisma: DB_URI /data/db.sqlite)] + end + + Entry --> Session + Entry --> BotGateway + Entry --> WebServer + WebServer --> NextApp + BotGateway <--> LavalinkInternal + BotGateway <--> Session + NextApp <--> Cache + Session <--> Cache + Session --> DB +``` + +--- + +## 🧠 Session Layer & State Management + +The bot does **not** execute blocking database queries during command execution. All live server configurations and active states reside in memory within the **`SessionManager`**: + +```mermaid +flowchart TD + Command[Slash Command Execution] -->|Read / Write| SM[SessionManager in Memory] + SM -->|Immediate Sync| Cache[In-Memory Redis Cache] + SM -->|Async Mutation| Queue[Serial persistQueue] + Queue -->|Order-Preserving Write| Prisma[Prisma ORM Client] + Prisma --> DB[(Database: SQLite or PostgreSQL)] + DB -->|Hydration on Startup| SM +``` + +- **Hydration at Boot**: When `session.init()` runs during startup, all server settings, custom playlists, reminder schedules, and stream alert configurations are loaded into memory. +- **Asynchronous Persistence**: Database writes are placed onto a FIFO `persistQueue` that enforces relational integrity (`User → Guild → GuildMember`) without delaying Discord interaction responses. + +--- + +## 🗄️ Database Tier (`DB_URI`) & Schema + +Master-Bot features zero-ops database auto-configuration via `DB_URI`: +- **SQLite (Default)**: Automatically prepares `packages/db/prisma/schema.prisma` with `provider = "sqlite"` and `file:/data/db.sqlite`. Creates the directory on boot. +- **PostgreSQL**: When `DB_URI` begins with `postgresql://`, the prepare script sets `provider = "postgresql"` and prepares Prisma for external clustering. + +```mermaid +erDiagram + USER ||--o{ PLAYLIST : "owns" + USER ||--o{ GUILDMEMBER : "member of" + USER ||--o{ REMINDER : "schedules" + USER ||--o{ TICKET : "creates" + GUILD ||--o{ GUILDMEMBER : "contains" + GUILD ||--o{ PLAYLIST : "scopes" + GUILD ||--o{ REMINDER : "scopes" + GUILD ||--o{ TICKET : "hosts" + GUILD ||--o{ TWITCHNOTIFY : "monitors" + GUILD ||--o{ YOUTUBENOTIFY : "monitors" + PLAYLIST ||--o{ SONG : "contains" + + USER { + string discordId PK + string name + datetime createdAt + } + GUILD { + string id PK + string name + int volume + string logChannel + boolean logChannelEnabled + string welcomeMessage + boolean welcomeMessageEnabled + string ticketChannel + boolean ticketEnabled + } + PLAYLIST { + string name + string userId + string guildId + } + SONG { + int id PK + string title + int length + string identifier + int playlistId FK + } + REMINDER { + int id PK + datetime dateTime + string event + boolean repeat + string userId + string guildId FK + } + YOUTUBENOTIFY { + string channelId PK + string title + string channelIds + boolean isLive + } +``` + +--- + +## 🧩 Command & Submodule Architecture + +Commands avoid bloated monolith files by adopting the **Modular Option Submodules** pattern: +- **Consolidated Slash Commands**: Single command registration with options (`/gif [tag]`, `/set [option]`) keeps the bot well within Discord's 100 application command ceiling. +- **Submodules in `lib/`**: Individual options are isolated into discrete TypeScript files under `src/lib/<feature>/options/`, managed through a typed `registry.ts`. +- **Themed Embeds**: Rich messages are generated through factory functions in `src/lib/embeds/` ensuring visual consistency. + +--- + +## 🚀 Bootstrap & Lifecycle Sequence + +```mermaid +sequenceDiagram + autonumber + participant Main as index.ts (main) + participant Session as SessionManager + participant Discord as Discord Gateway + participant Audio as Lavalink Audio + participant Web as Next.js Web Server + + Main->>Audio: Start embedded Lavalink v4 (if enabled) + Main->>Session: session.init() (hydrate from DB_URI) + Main->>Discord: client.login(DISCORD_TOKEN) + Discord-->>Main: Ready Event fired + Main->>Web: startWebServer(INTERNAL_URL 0.0.0.0:3000) + Main->>Session: Start background monitors (YouTube, Twitch, Reminders) +``` + +--- + +## 🔗 Related Guides +- [Home](Home) — Return to wiki main page +- [Developer Guide](Development-Guide) — How to add new commands and listeners +- [Configuration](Configuration) — Master `.env` reference +- [Deployment](Deployment) — Production self-hosting and container deployment + +--- +[Home](Home) • [Documentation Index](Home) • [GitHub Repository](https://github.com/galnir/Master-Bot) \ No newline at end of file diff --git a/wiki/Commands.md b/wiki/Commands.md new file mode 100644 index 000000000..e69cc6946 --- /dev/null +++ b/wiki/Commands.md @@ -0,0 +1,212 @@ +# ⌨️ Commands Reference & Usage Guide + +Master-Bot provides an extensive suite of **slash commands** across Music, Moderation, Utility, Entertainment, and Notifications. Every command is Discord slash-command native with type-safe argument validation and interactive embeds. + +--- + +## 📑 Table of Contents +1. [Overview & Permissions](#-overview--permissions) +2. [Command Architecture & Flow](#-command-architecture--flow) +3. [🎵 Music Suite](#-music-suite) +4. [🔨 Moderation Suite](#-moderation-suite) +5. [⚙️ `/set` Server Configuration Hub](#️-set-server-configuration-hub) +6. [🔔 YouTube & Twitch Alerts](#-youtube--twitch-alerts) +7. [🎭 Fun & Reaction GIFs](#-fun--reaction-gifs) +8. [🎲 General Utility & Games](#-general-utility--games) +9. [Related Guides](#-related-guides) + +--- + +## 📖 Overview & Permissions + +Commands automatically validate Discord member permissions and hierarchy before execution: + +| Scope | Required Permission | Applicable Commands | +| :--- | :--- | :--- | +| **Server Admin** | `ManageGuild` or `Administrator` | `/set` (all options) | +| **Moderators** | `BanMembers` | `/ban` | +| **Moderators** | `KickMembers` | `/kick` | +| **Moderators** | `ModerateMembers` | `/timeout` | +| **Moderators** | `ManageMessages` | `/purge` | +| **Moderators** | `ManageChannels` | `/slowmode` | +| **Members** | Standard Member (`SendMessages`) | Music, GIFs, Reminders, Games, Utilities | + +> [!NOTE] +> All moderation commands enforce **role hierarchy**: the bot cannot moderate members whose highest role is equal to or higher than the bot's role or the executing moderator's role. + +--- + +## 📐 Command Architecture & Flow + +```mermaid +flowchart TD + User([User executes slash command]) --> DiscordGateway[Discord API Gateway] + DiscordGateway --> Sapphire[Sapphire Command Handler] + Sapphire --> Preconditions{Preconditions Check} + + Preconditions -->|Command Disabled| Denied[commandDenied Event / Error Reply] + Preconditions -->|Insufficient Permissions| PermDenied[Permission Error Reply] + Preconditions -->|Passed| Execute[chatInputRun Execution] + + Execute --> Submodules[Submodule / Registry Dispatch] + Submodules --> BackendService[Lavalink / SessionManager / Prisma DB] + BackendService --> EmbedFactory[lib/embeds/ Themed Embed Builder] + EmbedFactory --> Response([Interactive Discord Reply]) +``` + +--- + +## 🎵 Music Suite + +Requires Lavalink (`LAVA_ENABLED=true`) and the user to be connected to a voice channel. Supports YouTube, Spotify, SoundCloud, Twitch, Vimeo, and direct streams. + +| Command | Usage | Description | +| :--- | :--- | :--- | +| `/play` | `/play query: <song/url>` | Play a track, album, playlist, or search query. | +| `/pause` | `/pause` | Pause currently playing track. | +| `/resume` | `/resume` | Resume playback of paused track. | +| `/queue` | `/queue [page: 1-N]` | Display the interactive queued tracks and now-playing progress bar. | +| `/now-playing` | `/now-playing` | Display real-time progress bar, track details, and requester. | +| `/jump` | `/jump position: <number>` | Jump directly to a specific track index in the queue. | +| `/shuffle` | `/shuffle` | Randomize the order of tracks currently in queue. | +| `/seek` | `/seek timestamp: <01:30>` | Seek to a specific timestamp in the currently playing track. | +| `/remove` | `/remove position: <number>` | Remove a track from the queue by its index. | +| `/move` | `/move from: <int> to: <int>` | Move a track to a new position in the queue. | +| `/skip` | `/skip` | Skip the currently playing track. | +| `/skipall` | `/skipall` | Clear the remaining queue and stop audio. | +| `/leave` | `/leave` | Disconnect the bot from voice and clear the session. | +| `/volume` | `/volume [volume: 1-100]` | Adjust or view audio volume for the current guild. | +| `/lyrics` | `/lyrics [song: <query>]` | Fetch song lyrics via the Genius API. | +| `/bassboost` | `/bassboost` | Toggle DSP bass boost audio filter. | +| `/karaoke` | `/karaoke` | Toggle vocal suppression DSP audio filter. | +| `/nightcore` | `/nightcore` | Toggle nightcore (pitch and tempo elevation) DSP filter. | +| `/vaporwave` | `/vaporwave` | Toggle vaporwave (slowed tempo and reverb) DSP filter. | +| `/music-trivia` | `/music-trivia` | Start a music trivia guessing game based on queued tracks. | +| `/stop-trivia` | `/stop-trivia` | Terminate the current trivia game and show final scores. | +| `/create-playlist` | `/create-playlist name: <name>` | Create a custom per-server playlist. | +| `/save-to-playlist` | `/save-to-playlist name: <name>` | Save the currently playing track into your playlist. | +| `/my-playlists` | `/my-playlists` | View all playlists created by you on this server. | +| `/display-playlist`| `/display-playlist name: <name>` | View paginated tracks inside a specific playlist. | +| `/delete-playlist` | `/delete-playlist name: <name>` | Permanently delete a custom playlist. | +| `/remove-from-playlist` | `/remove-from-playlist name: <n> track: <i>` | Remove a track index from a custom playlist. | + +--- + +## 🔨 Moderation Suite + +Comprehensive moderation commands with role hierarchy protection, audit logging, and styled embeds. + +| Command | Usage | Description | +| :--- | :--- | :--- | +| `/ban` | `/ban member: @User [reason: Text]` | Permanently ban a member from the guild. | +| `/kick` | `/kick member: @User [reason: Text]` | Kick a member from the guild. | +| `/timeout` | `/timeout member: @User duration: <time> [reason: Text]` | Apply a Discord timeout (e.g. `60s`, `10m`, `2h`, `1d`). | +| `/slowmode` | `/slowmode duration: <time>` | Set channel slowmode delay (e.g. `0s`, `5s`, `15m`, `6h`). | +| `/purge` | `/purge amount: 1-100` | Bulk-delete up to 100 recent messages in the current channel. | + +--- + +## ⚙️ `/set` Server Configuration Hub + +The `/set` command is the central server management hub for Master-Bot. It consolidates 22 configuration actions into a single command with options, ensuring that bot registrations remain well within Discord application limits: + +```text +/set [option: Option] [channel: #channel] [value: Text] [enabled: True/False] [role: @Role] [number: 1-100] [alerts: Type] +``` + +> [!TIP] +> Running `/set` without any arguments defaults to displaying the server's current settings overview (`view`). + +### Setting Options Directory: + +| Option Name | Parameters | Description | +| :--- | :--- | :--- | +| `view` | *(None)* | Display an overview embed of all server settings (default). | +| `welcome-channel` | `channel: #channel` | Set the target text channel for new member greetings. | +| `welcome-message` | `value: "Text"` | Set custom welcome message (`{user}`, `{server}`, `{memberCount}`). | +| `welcome-toggle` | `enabled: True/False` | Enable or disable new member welcome greetings. | +| `welcome-test` | *(None)* | Test the welcome greeting in the configured channel. | +| `youtube-add` | `value: "@channel"`, `channel: #channel`, `[alerts: all/streams/uploads]` | Add YouTube alert (supports text & forum channels). | +| `youtube-remove` | `value: "@channel"`, `channel: #channel` | Remove a YouTube alert subscription. | +| `youtube-list` | *(None)* | List all monitored YouTube channels in the guild. | +| `twitch-add` | `value: "streamer"`, `channel: #channel` | Add Twitch streamer live alerts (supports text & forum channels). | +| `twitch-remove` | `value: "streamer"`, `channel: #channel` | Remove a Twitch streamer subscription. | +| `twitch-list` | *(None)* | List all monitored Twitch streamers in the guild. | +| `log-channel` | `channel: #channel` | Set channel for audit and moderation logs. | +| `log-toggle` | `enabled: True/False` | Enable or disable audit logging. | +| `log-disable` | *(None)* | Disable audit logging. | +| `ticket-channel` | `channel: #channel` | Set channel for support ticket creation panels. | +| `ticket-panel` | *(None)* | Post the interactive support ticket button panel. | +| `ticket-role` | `role: @Role` | Assign staff/support role with ticket permissions. | +| `ticket-role-disable` | *(None)* | Remove assigned ticket support role. | +| `ticket-toggle` | `enabled: True/False` | Enable or disable support ticket system. | +| `ticket-transcript` | `channel: #channel` | Set channel for archiving closed ticket transcripts. | +| `ticket-transcript-disable` | *(None)* | Disable ticket transcript archiving. | +| `default-volume` | `number: 1-100` | Set server default audio playback volume. | + +--- + +## 🔔 YouTube & Twitch Alerts + +Master-Bot features background polling monitors for YouTube and Twitch creators. Alerts can be delivered to **standard text channels** or **forum channels** (creating automated discussion threads). + +| Command | Usage | Description | +| :--- | :--- | :--- | +| `/set` (`youtube-add`) | `/set option: youtube-add value: @MKBHD channel: #feed alerts: all` | Subscribe to live streams and/or video uploads. | +| `/set` (`twitch-add`) | `/set option: twitch-add value: shroud channel: #streams` | Subscribe to live stream broadcast alerts. | +| `/twitch-status` | `/twitch-status streamer: <username>` | Check live broadcast status of a Twitch streamer on-demand. | + +--- + +## 🎭 Fun & Reaction GIFs + +All reaction commands are unified under a single `/gif` command with modular submodules in `apps/bot/src/lib/gifs/options/`: + +```text +/gif [tag: Tag] [query: Keyword] [target: @User] +``` + +### Tag Presets: +- `amongus` — Random Among Us reaction +- `anime` — Anime reactions +- `baka` — "Baka" anime reaction +- `cat` — Cute cat media +- `doggo` — Cute dog media +- `gintama` — Gintama anime moments +- `hug` — Warm hug reaction (supports `target: @User`) +- `jojo` — JoJo's Bizarre Adventure GIF +- `pat` — Gentle headpat reaction (supports `target: @User`) +- `slap` — Playful slap reaction (supports `target: @User`) +- `waifu` — Waifu illustration from Waifu.im + +--- + +## 🎲 General Utility & Games + +| Command | Usage | Description | +| :--- | :--- | :--- | +| `/help` | `/help [command: <name>]` | Interactive paginated help embed and command lookup. | +| `/dashboard` | `/dashboard` | Direct link to the server's web management dashboard. | +| `/reminder` | `/reminder duration: <time> message: <text>` | Set a DM reminder (e.g. `20m`, `3h`, `1d`). | +| `/poll` | `/poll question: <question>` | Create an emoji-reaction voting poll. | +| `/weather` | `/weather city: <location>` | Current weather and forecast for any city. | +| `/translate` | `/translate text: <text> target_lang: <lang>` | Translate text using Google Translate. | +| `/game-search` | `/game-search name: <title>` | Search game details, ratings, and platforms via IGDB. | +| `/world-news` | `/world-news [query: <topic>]` | Search international news headlines via NewsAPI. | +| `/connect-four` | `/connect-four opponent: @User` | Play Connect Four with another member. | +| `/tic-tac-toe` | `/tic-tac-toe opponent: @User` | Play Tic-Tac-Toe with another member. | +| `/rockpaperscissors` | `/rockpaperscissors` | Play Rock Paper Scissors against the bot. | +| `/8ball` | `/8ball question: <text>` | Magic 8-ball answers to life's questions. | +| `/urban` | `/urban term: <query>` | Look up definitions on Urban Dictionary. | + +--- + +## 🔗 Related Guides +- [Home](Home) — Return to wiki main page +- [Developer Guide](Development-Guide) — How to add new commands and options +- [Stream Alerts](Reminders-and-Twitch) — YouTube and Twitch notification setup +- [Configuration](Configuration) — Environment variable documentation +- [Deployment](Deployment) — Production self-hosting guide + +--- +[Home](Home) • [Documentation Index](Home) • [GitHub Repository](https://github.com/galnir/Master-Bot) \ No newline at end of file diff --git a/wiki/Configuration.md b/wiki/Configuration.md new file mode 100644 index 000000000..84ac640da --- /dev/null +++ b/wiki/Configuration.md @@ -0,0 +1,152 @@ +# ⚙️ Configuration & Environment Reference + +Master-Bot is configured entirely through a single `.env` file located in the workspace root. Both the bot and the dashboard share this environment. + +--- + +## 📑 Table of Contents +1. [Overview & Configuration Flow](#-overview--configuration-flow) +2. [🗄️ Database (`DB_URI`)](#️-database-db_uri) +3. [🤖 Discord & Dashboard URLs](#-discord--dashboard-urls) +4. [🎵 Lavalink & Audio Gateway](#-lavalink--audio-gateway) +5. [📺 YouTube OAuth Credentials](#-youtube-oauth-credentials) +6. [🎧 Spotify & Third-Party APIs](#-spotify--third-party-apis) +7. [🚩 Feature Toggles](#-feature-toggles) +8. [🔑 API Keys & Acquisition Guide](#-api-keys--acquisition-guide) +9. [Related Guides](#-related-guides) + +--- + +## 📖 Overview & Configuration Flow + +```mermaid +flowchart TD + Env[Workspace .env] --> BotEnv[apps/bot/src/env.ts] + Env --> DashEnv[apps/dashboard/src/env.mjs] + Env --> DBPackage[packages/db/scripts/prepare-schema.mjs] + + BotEnv --> BotGateway[Discord Bot Gateway & Music] + DashEnv --> WebServer[Next.js 15 Web Dashboard] + DBPackage --> Prisma[Prisma Schema & Client: DB_URI] +``` + +--- + +## 🗄️ Database (`DB_URI`) + +```env +DB_URI="file:/data/db.sqlite" +``` + +Master-Bot uses **Prisma ORM** with a strict dual-database architecture: +- **SQLite (Default Zero-Ops)**: When set to a `file:...` URI (e.g. `file:/data/db.sqlite`), Master-Bot uses local SQLite storage. The directory is created automatically on boot. No external database server or Docker container is needed. +- **PostgreSQL (Production Scaling)**: Set `DB_URI="postgresql://user:password@host:5432/dbname?schema=public"` for high-throughput multi-server production environments. + +> [!IMPORTANT] +> The project strictly enforces a single environment variable: `DB_URI`. Redundant aliases such as `DATABASE_URL` or `DB_URL` are not supported. + +--- + +## 🎨 Dashboard Theme & Appearance + +| Variable | Default | Options | Description | +| :--- | :---: | :--- | :--- | +| `DASHBOARD_THEME` | `dark` | `dark`, `light`, `glassmorphism`, `cyberpunk`, `dracula`, `nord`, `emerald` | Default visual theme rendered on first load. Users can toggle freely from the theme picker dropdown. | +| `DASHBOARD_COLOR_SCHEME` | `default` | `default`, `purple`, `blue`, `emerald`, `rose`, `amber`, `indigo`, `crimson`, `teal`, `sunset`, `cyan` | Default accent color scheme overriding buttons, focus rings, and highlights. | + +--- + +## 🤖 Discord & Dashboard URLs + +| Variable | Required | Description | +| :--- | :---: | :--- | +| `DISCORD_TOKEN` | ✅ | Bot authentication token from the [Discord Developer Portal](https://discord.com/developers/applications). | +| `DISCORD_CLIENT_ID` | ✅ | Discord application Client ID (used for OAuth2 and invite generation). | +| `DISCORD_CLIENT_SECRET` | ✅ | Discord application Client Secret (used for web dashboard login). | +| `NEXTAUTH_SECRET` | ✅ | 32+ character random signing secret (`openssl rand -base64 32`). | +| `INTERNAL_URL` | ✅ | Host and port binding (`0.0.0.0:3000`). Binds to `0.0.0.0` so the service listens across all network interfaces, allowing both internal SSR requests and public traffic to reach the dashboard. | +| `PUBLIC_URL` | ✅ | The canonical public HTTPS domain of your dashboard (e.g. `https://your-domain.com`). Used for dashboard links and OAuth callbacks. | +| `DISCORD_CALLBACK_URL` | ✅ | Public bot invite authorization URL with administrator permissions. | + +--- + +## 🎵 Lavalink & Audio Gateway + +| Variable | Default | Description | +| :--- | :---: | :--- | +| `LAVA_HOST` | `localhost` | Hostname or IP address of the Lavalink v4 server. | +| `LAVA_PORT` | `2333` | Port number for the Lavalink HTTP and WebSocket gateway. | +| `LAVA_PASS` | `youshallnotpass` | Authorization password (must match `application.yml`). | +| `LAVA_SECURE` | `false` | Set to `true` to enable TLS (WSS/HTTPS) for remote Lavalink nodes. | +| `LAVA_EXTERNAL` | `false` | Set to `true` when connecting to a remote external Lavalink node. | + +--- + +## 📺 YouTube OAuth Credentials + +| Variable | Required | Description | +| :--- | :---: | :--- | +| `YOUTUBE_CLIENT_ID` | Optional | Google Cloud OAuth 2.0 Client ID. Powers YouTube live stream and video upload alerts. | +| `YOUTUBE_CLIENT_SECRET` | Optional | Google Cloud OAuth 2.0 Client Secret for YouTube alerts. | +| `YOUTUBE_REFRESH_TOKEN` | Optional | OAuth 2.0 refresh token for stream alerts and embedded Lavalink YouTube playback. | +| `YOUTUBE_CIPHER_URL` | `https://cipher.kikkia.dev/` | Remote endpoint for deciphering complex YouTube streaming signatures. | +| `YOUTUBE_CIPHER_PASSWORD` | Optional | Password for self-hosted cipher instances. | + +> [!TIP] +> Setting `YOUTUBE_CLIENT_ID`, `YOUTUBE_CLIENT_SECRET`, and `YOUTUBE_REFRESH_TOKEN` enables authenticated playback in Lavalink, bypassing YouTube IP throttling and robot verification challenges. + +--- + +## 🎧 Spotify & Third-Party APIs + +| Variable | Required | Description | +| :--- | :---: | :--- | +| `SPOTIFY_CLIENT_ID` | Optional | Spotify Developer client ID (resolves Spotify track/album metadata). | +| `SPOTIFY_CLIENT_SECRET` | Optional | Spotify Developer client secret. | +| `TWITCH_CLIENT_ID` | Optional | Twitch Developer Client ID (powers `/twitch-status`, Twitch alerts, and IGDB game lookups). | +| `TWITCH_CLIENT_SECRET` | Optional | Twitch Developer Client Secret. | +| `KLIPY_API` | Optional | API key from Klipy for reaction GIFs and search in `/gif`. | +| `NEWS_API` | Optional | NewsAPI key for global headline searches in `/world-news`. | +| `GENIUS_API` | Optional | Genius API client access token for fetching track lyrics in `/lyrics`. | + +--- + +## 🚩 Feature Toggles + +Disable individual bot features without code modifications: + +| Variable | Default | Affected Capabilities | +| :--- | :---: | :--- | +| `LAVA_ENABLED` | `true` | Audio engine and all music commands (`/play`, `/queue`, etc.). | +| `GIFS_ENABLED` | `true` | Animated reaction GIFs and search (`/gif`). | +| `TWITCH_ENABLED` | `true` | Twitch live streamer polling notifications and `/twitch-status`. | +| `NEWS_ENABLED` | `true` | International news headlines command (`/world-news`). | +| `IGDB_ENABLED` | `true` | Video game database search command (`/game-search`). | + +--- + +## 🔑 API Keys & Acquisition Guide + +Every external API utilized by Master-Bot offers a free access tier: + +| Service | Portal | Purpose | +| :--- | :--- | :--- | +| **Discord** | [Developer Portal](https://discord.com/developers/applications) | Bot token, client ID, client secret (mandatory). | +| **Spotify** | [Developer Dashboard](https://developer.spotify.com/dashboard) | Resolving Spotify playlist/track links to YouTube audio streams. | +| **Twitch** | [Developer Console](https://dev.twitch.tv/console/apps) | Stream monitoring alerts and IGDB game database queries. | +| **Google Cloud** | [Google Cloud Console](https://console.cloud.google.com/) | OAuth credentials for YouTube live notifications and unthrottled streaming. | +| **NewsAPI** | [NewsAPI Registration](https://newsapi.org/register) | Global news headlines (`/world-news`). | +| **Genius** | [Genius API Clients](https://genius.com/api-clients) | Song lyrics lookup (`/lyrics`). | +| **Klipy** | [Klipy](https://klipy.com/) | Reaction GIFs and animations (`/gif`). | + +--- + +## 🔗 Related Guides +- [Home](Home) — Return to wiki main page +- [Getting Started](Getting-Started) — Prerequisites and initial workspace setup +- [Commands Reference](Commands) — Full breakdown of all slash commands +- [Stream Alerts](Reminders-and-Twitch) — YouTube and Twitch notification setup +- [Deployment](Deployment) — Production self-hosting and container deployment + +--- +[Home](Home) • [Documentation Index](Home) • [GitHub Repository](https://github.com/galnir/Master-Bot) \ No newline at end of file diff --git a/wiki/Dashboard.md b/wiki/Dashboard.md new file mode 100644 index 000000000..07c344dad --- /dev/null +++ b/wiki/Dashboard.md @@ -0,0 +1,82 @@ +# 🌐 Web Dashboard + +The **Master-Bot Dashboard** is a Next.js 15 management console where you configure everything `/set` can — plus studios that live better on a big screen. + +## 🧱 Stack + +| Layer | Technology | +| --- | --- | +| Framework | Next.js 15 (App Router), React 18 | +| API | tRPC v11 (client/react-query/server) + `superjson` | +| Data fetching | TanStack React Query 5 | +| Auth | NextAuth v5 (beta) via `@master-bot/auth` — Discord OAuth (`identify guilds email` scopes) | +| Styling | Tailwind CSS 3.4, Radix UI primitives, custom UI components | +| Backend data | Prisma Client (`@master-bot/db`) — reads the same SQLite database the bot writes | + +## 🔐 Authentication + +```mermaid +sequenceDiagram + participant U as User + participant D as Dashboard + participant A as NextAuth (Discord) + U->>D: Visit /dashboard + D->>A: Sign in with Discord + A->>D: Session (id, discordId, avatar) + D->>D: List servers where bot is present +``` + +- Discord OAuth grants the `identify`, `guilds`, and `email` scopes. +- `packages/auth` uses a custom Prisma adapter whose `createUser` **upserts by Discord ID**, linking dashboard users to the same `User` rows the bot manages. +- The `Session` type is augmented with `user.id` and `user.discordId` for API calls. + +## 🗺️ Pages + +| Route | Studio | +| --- | --- | +| `/` | Landing page with features + invite. | +| `/dashboard` | Server hub — pick a server where the bot is present. | +| `/dashboard/[server_id]` | Server layout with per-guild navigation. | +| `…/welcome-message` | Welcome channel, template editor, toggle, and send-test actions. | +| `…/log-channel` | Audit-log channel + the full **20-event trigger switchboard** (`log-events-form`). | +| `…/tickets` | Ticket channel/role/transcript configuration. | +| `…/reminders` | View and manage server reminders. | +| `…/commands/[command_id]` | Per-command info and per-server command toggles. | +| `/dashboard/music` | Global music settings. | +| `/dashboard/broadcast` | Rich broadcast composer for announcements. | +| `/dashboard/integrations` | Link external services / manage credentials. | +| `/dashboard/system` | Runtime health, version, uptime telemetry. | + +## 🎨 Visual Themes & Accent Schemes + +The dashboard features **7 visual themes** and **10 accent color schemes**: + +- **Visual Themes:** + - **Dark** (Default): Slate and charcoal midnight aesthetic. + - **Light**: Crisp daylight contrast. + - **Glassmorphism**: Translucent frosted cards with ambient radial glow gradients. + - **Cyberpunk**: Neon yellow, electric cyan border highlights, and tech grid styling. + - **Dracula**: Classic dark gothic purple with neon pink accents. + - **Nord**: Arctic frost palette with muted polar blue hues. + - **Emerald**: Deep evergreen forest with glowing mint highlights. + +- **Accent Color Schemes:** + - **Theme Default**, **Amethyst Purple**, **Ocean Blue**, **Emerald Green**, **Rose Pink**, **Amber Gold**, **Indigo Violet**, **Crimson Ruby**, **Teal Aqua**, **Sunset Coral**, **Electric Cyan**. + +Themes and accents can be selected dynamically from the header dropdown menu. Preferences are preserved in `localStorage` and can be set server-side via `DASHBOARD_THEME` and `DASHBOARD_COLOR_SCHEME`. + +## ⚙️ Data Flow + +```mermaid +flowchart LR + SUB["Studio forms (client)"] --> RQ["React Query mutations"] + RQ --> T["tRPC router"] + T --> P["Prisma Client"] + P --> DB[("SQLite: /data/db.sqlite")] + DB --> SM["Bot SessionManager<br/>(hydrated at boot)"] + SM --> B["Discord bot behavior"] +``` + +Because the bot **hydrates from the database at every boot**, a setting you save in the dashboard is live the next time the bot picks it up (and vice versa for `/set`). + +> ⚠️ **Tip:** In container setups, mount `/data` as a persistent volume — see [Deployment](Deployment). \ No newline at end of file diff --git a/wiki/Deployment.md b/wiki/Deployment.md new file mode 100644 index 000000000..79015c8da --- /dev/null +++ b/wiki/Deployment.md @@ -0,0 +1,263 @@ +# 🚀 Deployment Guide (Self-Hosted) + +Master-Bot runs as a consolidated, single-process Node.js runtime that hosts both the **Discord Bot Gateway** and the **Next.js Web Dashboard** on the exact same service and port (`PORT`, default `3000`). + +Thanks to the built-in zero-binary **`ioredis-mock`** cache and **SQLite persistence**, Master-Bot boots up instantly on any machine or VPS with a simple `pnpm install` and `pnpm start` — without installing an external Redis binary or managing a separate database server. + +--- + +## ⚡ Quick Architecture Overview + +```mermaid +flowchart TD + subgraph SingleProcess ["Single Node.js Process (Port 3000)"] + Bot["Discord Bot Gateway<br/>(Sapphire Framework)"] + Dashboard["Web Dashboard Service<br/>(Next.js App Router /dashboard)"] + Cache[("In-Memory Redis Cache<br/>(ioredis-mock)")] + KeepAlive["Keep-Alive Pinger<br/>(Every 10 mins)"] + end + + subgraph Storage ["Persistence"] + DB[("SQLite Database<br/>/data/db.sqlite")] + end + + subgraph AudioEngine ["Audio Engine (Lavalink v4 Server)"] + Lavalink["Lavalink v4<br/>(java -jar Lavalink.jar or container)"] + end + + Bot <--> Cache + Dashboard <--> Cache + Bot --> DB + Dashboard --> DB + Bot -.->|"Audio (WSS)"| Lavalink + KeepAlive -->|"GET /health"| Dashboard +``` + +- **Single Console Window:** No child-process console popups or separate terminal windows. +- **Only Two Ports Required Across Entire System:** + 1. `PORT` (default `3000`): Unified web port shared by the Discord bot gateway, Next.js web dashboard (`/dashboard`), and health/keep-alive endpoints (`/health`). + 2. `LAVA_PORT` (default `2333`): The Lavalink audio server port (bot connects over WebSocket). +- **Zero Redis Server Process:** In-process in-memory `ioredis-mock` shares live state seamlessly between the bot and dashboard with zero separate binaries, processes, or ports. +- **SQLite Persistence Layer:** Single embedded file (`/data/db.sqlite`) preserves all guild configurations, roles, tickets, and playlists across restarts without requiring an external database server or port. + +--- + +## 🎵 Audio Engine: External Lavalink + +Master-Bot keeps the audio engine **separate** from the app process: the bot + dashboard run in one Node.js process, and music is served by a dedicated **Lavalink v4** server. Music commands require that server to be reachable. + +Self-host Lavalink via Docker or a dedicated VPS (see [Music & Lavalink](Music)): + +```env +LAVA_ENABLED=true +LAVA_EXTERNAL=true +LAVA_HOST=your-lavalink-host.com +LAVA_PORT=443 +LAVA_PASS=youshallnotpass +LAVA_SECURE=true +``` + +> ⚠️ **Configuration:** whichever server you use must run with a Lavalink v4 config equivalent to **Master-Bot's `application.yml.example`**, which contains custom fixes (YouTube multi-client + OAuth via the `youtube-plugin`, Spotify → YouTube resolution via `lavasrc`, tuned streaming buffers) that are **broken in Lavalink's stock default config**. Do **not** use the `application.yml` from the Lavalink release as-is — see [Lavalink Configuration](Lavalink). +> +> ℹ️ **While your Lavalink server is offline,** set `LAVA_ENABLED=false` to run the bot and dashboard normally without music commands (no memory overhead, no crash risk). + +--- + +## 📦 Hosting Strategy: Low-Cost VPS vs. Cloud PaaS + +> [!IMPORTANT] +> **Why Cloud PaaS Hosting (Render, Railway, Fly.io, etc.) is Not Ideal:** +> 1. **Ephemeral File Storage**: PaaS containers wipe their local filesystem on every restart, redeploy, or dyno sleep cycle. This destroys SQLite persistence (`/data/db.sqlite`) unless you configure and pay for external managed database add-ons. +> 2. **Process & Memory Throttling**: Discord gateway bots require persistent, low-latency WebSocket connections. Free and entry-tier cloud containers frequently sleep or throttle after periods of inactivity, dropping Discord voice and gateway sessions. +> 3. **High Costs**: Running Node.js, Next.js App Router, and Lavalink audio requires 1.5–2 GB RAM. Cloud platforms charge steep monthly fees for this memory, whereas a dedicated VPS offers 4 GB RAM for as little as €3.79/month. +> 4. **Domain & OAuth Reputation**: Default cloud subdomains (e.g., `*.herokuapp.com`, `*.onrender.com`) frequently suffer from automated safe-browsing blocks, breaking Discord OAuth and YouTube authentication. +> +> **Recommendation**: Host Master-Bot on a **budget-friendly Linux VPS** (via Docker or PM2) for permanent file storage, fixed IP addresses, and 100% uptime. For users who specifically require cloud hosting, **Heroku** is supported as an optional alternative (see below). + +--- + +## 🌐 Recommended Low-Cost VPS Providers & Hosts + +The following reputable, low-cost VPS providers offer excellent price-to-performance, unthrottled networking, and full root access for Master-Bot: + +| Provider | Starting Price | Specs / Recommended Plan | Key Advantages | Datacenter Regions | +| :--- | :--- | :--- | :--- | :--- | +| [**Hetzner Cloud**](https://www.hetzner.com/cloud) | **~€3.79 / mo** | **CX22** (2 vCPU, 4 GB RAM, 40 GB NVMe) | **Best overall value.** Exceptional CPU & NVMe speeds, 20 TB traffic. | Germany, Finland, USA | +| [**OVHcloud**](https://www.ovhcloud.com/en/vps/) | **~$4.20 / mo** | **Starter VPS** (1 vCPU, 2 GB RAM, 20 GB SSD) | Unmetered bandwidth, industry-leading anti-DDoS mitigation. | US, Canada, Europe, APAC | +| [**DigitalOcean**](https://www.digitalocean.com/) | **~$4.00 - $6.00 / mo** | **Basic Droplet** (1 vCPU, 1-2 GB RAM, 25 GB NVMe) | 1-Click Docker marketplace app, ultra-reliable network, easy snapshotting. | Global (NYC, SFO, AMS, SGP, etc.) | +| [**Linode (Akamai)**](https://www.linode.com/) | **~$5.00 / mo** | **Nanode 1GB / Shared 2GB** (1-2 vCPU, 1-2 GB RAM) | 99.99% network SLA, 24/7 technical support, simple management. | Global (11+ locations) | +| [**Vultr**](https://www.vultr.com/) | **~$3.50 - $5.00 / mo** | **Cloud Compute** (1 vCPU, 1-2 GB RAM, 25-32 GB NVMe) | Over 32+ global datacenter locations, instant hourly provisioning. | Global (32+ cities) | +| [**Contabo**](https://contabo.com/) | **~$5.50 / mo** | **Cloud VPS S** (4 vCPU, 8 GB RAM, 50 GB NVMe) | Maximum RAM and vCPU per dollar. Ideal for bot + dashboard + Lavalink all in one. | US, EU, UK, Asia, Australia | + +### 💡 VPS Sizing Guide: +- **Bot + Web Dashboard only (SQLite + ioredis-mock):** 1 vCPU, 1 GB RAM (e.g., DigitalOcean $4, Vultr $3.50). +- **Bot + Dashboard + External Lavalink Audio Engine:** 2 vCPU, 2–4 GB RAM (e.g., Hetzner CX22, OVH Starter, Contabo VPS S). + +--- + +## 1. 🐳 VPS Deployment via Docker Compose (Recommended) + +Docker Compose provides the cleanest, most isolated setup on any Linux VPS (Ubuntu/Debian): + +### Step 1: Install Docker & Docker Compose on your VPS +```bash +# Update and install Docker +sudo apt update && sudo apt install -y docker.io docker-compose-v2 +sudo systemctl enable --now docker +``` + +### Step 2: Clone Master-Bot & Configure +```bash +git clone https://github.com/galnir/Master-Bot.git +cd Master-Bot + +# Copy environment template and fill in your credentials +cp .env.example .env +nano .env +``` + +### Step 3: Launch Containers +```bash +docker compose --env-file docker.env up -d --build +``` + +- Docker runs **Master-Bot** (`apps/bot` + `apps/dashboard`) on port `3000` and a dedicated **Lavalink v4** container on port `2333`. +- SQLite persists in the volume `/data` (`/data/db.sqlite`), surviving all container restarts and updates. + +--- + +## 2. 🖥️ VPS Deployment via Node.js & PM2 (Native Performance) + +If you prefer running directly on Ubuntu/Debian without Docker: + +### Step 1: Install Node.js 20+ and pnpm +```bash +# Install Node.js 20 LTS via NodeSource +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt install -y nodejs git + +# Install pnpm and PM2 process supervisor +sudo npm install -g pnpm pm2 +``` + +### Step 2: Install and Build Master-Bot +```bash +git clone https://github.com/galnir/Master-Bot.git +cd Master-Bot +cp .env.example .env +nano .env + +# Install dependencies and compile monorepo +pnpm install +pnpm build +``` + +### Step 3: Start with PM2 +```bash +pm2 start pnpm --name "master-bot" -- start +pm2 save +pm2 startup +``` + +### Step 4: Optional SSL Reverse Proxy (Caddy) +To expose your dashboard securely at `https://your-domain.com`: +```bash +sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg +curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list +sudo apt update && sudo apt install -y caddy +``` +Edit `/etc/caddy/Caddyfile`: +```caddy +your-domain.com { + reverse_proxy localhost:3000 +} +``` +Reload Caddy: `sudo systemctl reload caddy`. Automatic Let's Encrypt SSL certificates are provisioned instantly. + +--- + +## 3. ☁️ Optional Cloud Hosting: Heroku + +For users who specifically prefer managed cloud hosting, Master-Bot can be deployed to **Heroku**. + +> [!WARNING] +> **Heroku Considerations:** +> - **Dyno Sleep:** Eco/Basic dynos sleep after 30 minutes of inactivity unless pinged. Set `KEEP_ALIVE_ENABLED=true` in your Heroku settings. +> - **Ephemeral Storage:** Heroku dynos reset their filesystems upon restart. If you use Heroku, configure an external PostgreSQL database via `DB_URI` (such as the Heroku Postgres add-on). +> - **Lavalink Audio:** Due to memory constraints on 512 MB dynos, do not run embedded Lavalink on Heroku. Set `LAVA_EXTERNAL=true` and point to an external Lavalink audio host. + +### Heroku Setup Steps: +1. **Create Heroku App:** + ```bash + heroku create my-master-bot + ``` +2. **Add Node.js Buildpack:** + ```bash + heroku buildpacks:set heroku/nodejs + ``` +3. **Provision PostgreSQL Database (Required for persistent storage on Heroku):** + ```bash + heroku addons:create heroku-postgresql:essential-0 + ``` +4. **Configure Environment Variables:** + ```bash + heroku config:set DISCORD_TOKEN="your-discord-token" \ + DISCORD_CLIENT_ID="your-client-id" \ + DISCORD_CLIENT_SECRET="your-client-secret" \ + NEXTAUTH_SECRET="random-32-char-secret" \ + NEXTAUTH_URL="https://my-master-bot.herokuapp.com" \ + PUBLIC_URL="https://my-master-bot.herokuapp.com" \ + LAVA_EXTERNAL="true" \ + LAVA_HOST="your-external-lavalink.com" \ + LAVA_PORT="443" \ + LAVA_PASS="youshallnotpass" \ + LAVA_SECURE="true" \ + KEEP_ALIVE_ENABLED="true" + ``` +5. **Deploy:** + ```bash + git push heroku main + ``` + +--- + +## 💾 Backups & Maintenance + +The SQLite database lives at `/data/db.sqlite`. + +To perform a hot backup while Master-Bot is running on your VPS: + +```bash +sqlite3 /data/db.sqlite ".backup 'backup-$(date +%F).db'" +``` + +Schedule this via a daily cron job (`crontab -e`): +```cron +0 3 * * * sqlite3 /data/db.sqlite ".backup '/path/to/backups/backup-\$(date +\%F).db'" +``` + +--- + +## 🔐 Production Environment Checklist + +| Variable | Required | Description | +| :--- | :---: | :--- | +| `DISCORD_TOKEN` | **Yes** | Bot authentication token from Discord Developer Portal. | +| `DISCORD_CLIENT_ID` | **Yes** | Discord Application Client ID. | +| `DISCORD_CLIENT_SECRET` | **Yes** | Discord Application Client Secret. | +| `NEXTAUTH_SECRET` | **Yes** | 32+ character random string to sign auth session cookies. | +| `PUBLIC_URL` | **Yes** | The public HTTPS URL of your application dashboard. | +| `INTERNAL_URL` | **Yes** | Host/port binding (`0.0.0.0:3000`) ensuring the service listens on all network interfaces. | +| `DB_URI` | No | Database connection URI string (default `file:/data/db.sqlite` for SQLite; PostgreSQL supported). | +| `PORT` | No | Listening port for web dashboard and health check (default: `3000`). | +| `KEEP_ALIVE_ENABLED` | No | Set to `true` to enable background HTTP pings to keep the process warm. | +| `LAVA_ENABLED` | No | Set to `true` to enable the audio engine (Lavalink v4). | +| `LAVA_EXTERNAL` | No | Set to `true` when connecting to an external Lavalink server. | +| `LAVA_HOST` | No | The Lavalink hostname / IP. | +| `LAVA_PORT` | No | Lavalink port (default `2333`; `443` for TLS external instances). | +| `LAVA_PASS` | No | Lavalink password (must match `application.yml`). | +| `LAVA_SECURE` | No | Set to `true` for TLS (WSS) remote instances. | +| `YOUTUBE_CLIENT_ID` | No | Google Cloud OAuth Client ID for YouTube stream alerts and authentication. | +| `YOUTUBE_CLIENT_SECRET` | No | Google Cloud OAuth Client Secret for YouTube stream alerts. | +| `YOUTUBE_REFRESH_TOKEN` | No | YouTube OAuth 2.0 refresh token for stream alerts and Lavalink. | \ No newline at end of file diff --git a/wiki/Development-Guide.md b/wiki/Development-Guide.md new file mode 100644 index 000000000..9951f19e2 --- /dev/null +++ b/wiki/Development-Guide.md @@ -0,0 +1,367 @@ +# 🛠️ Developer Guide: Building Commands, Listeners & Features + +Welcome to the **Master-Bot Developer Guide**! This comprehensive manual details the architecture, coding standards, and step-by-step processes for extending Master-Bot with new slash commands, event listeners, background monitors, and full-stack features. + +--- + +## 📑 Table of Contents +1. [Monorepo Architecture & Code Standards](#-monorepo-architecture--code-standards) +2. [Building a New Slash Command](#-building-a-new-slash-command) +3. [Modular Option Submodules Pattern](#-modular-option-submodules-pattern) +4. [Standardized Rich Embeds](#-standardized-rich-embeds) +5. [Building an Event Listener](#-building-an-event-listener) +6. [Creating Features & Background Monitors](#-creating-features--background-monitors) +7. [Database & State Management](#-database--state-management) +8. [Writing Unit Tests with Vitest](#-writing-unit-tests-with-vitest) +9. [Related Guides](#-related-guides) + +--- + +## 🏗️ Monorepo Architecture & Code Standards + +Master-Bot is organized as a unified **Turborepo** workspace powered by **pnpm**: + +```text +Master-Bot/ +├── apps/ +│ ├── bot/ # Discord.js v14 & Sapphire Framework Bot Gateway +│ └── dashboard/ # Next.js 15 App Router & tRPC v11 Web Dashboard +├── packages/ +│ ├── auth/ # Shared Discord OAuth configuration (NextAuth.js) +│ ├── config/ # Shared ESLint and Tailwind tooling +│ └── db/ # Shared Prisma ORM client & ioredis-mock fallback +├── tests/unit/ # Vitest monorepo testing suite (@helix-origin/vitest-suite) +└── wiki/ # Complete project documentation +``` + +### 📐 Mandatory Coding Standards + +Before contributing code, ensure you adhere to our unified project conventions: + +1. **CamelCase File Naming**: All TypeScript source and test files must use `camelCase.ts` (e.g. `youtubeAPI.ts`, `streamAlerts.ts`, `youtubeAlerts.test.ts`). +2. **Zero `index.ts` Files in `apps/bot` Subdirectories**: The Sapphire Framework piece loader automatically scans directory trees for runnable pieces. Naming files `index.ts` inside subdirectories causes conflicts; use `registry.ts` or `optionRegistry.ts` for modules. +3. **Submodules in `lib/`**: Complex command options, API helpers, and handlers must be modularized into discrete files under `src/lib/<feature>/` rather than stuffed into a monolithic command file. +4. **Typed Imports & Exports**: Use explicit type imports (`import type { ... } from '...'`) to ensure clean compilation under TypeScript with zero type pollution. + +```mermaid +flowchart TD + subgraph SapphireArchitecture [Sapphire Piece Architecture] + Command[commands/category/commandName.ts] + Listener[listeners/eventCategory/listenerName.ts] + Submodules[lib/feature/options/optionSubmodules.ts] + Embeds[lib/embeds/commands/featureEmbed.ts] + Session[lib/session/SessionManager.ts] + DB[(packages/db: Prisma ORM)] + end + + Command --> Submodules + Command --> Embeds + Command --> Session + Listener --> Session + Session --> DB +``` + +--- + +## ⚡ Building a New Slash Command + +Commands in Master-Bot are built on the **Sapphire Framework** and **discord.js v14**. + +### 1. File Location +Create your command file under `apps/bot/src/commands/<category>/<commandName>.ts`: +- Music commands go to `apps/bot/src/commands/music/` +- Moderation commands go to `apps/bot/src/commands/moderation/` +- Fun commands go to `apps/bot/src/commands/fun/` +- General/Utility commands go to `apps/bot/src/commands/other/` + +### 2. Basic Command Boilerplate + +```typescript +import { ApplyOptions } from '@sapphire/decorators'; +import { Command } from '@sapphire/framework'; +import type { ChatInputCommandInteraction } from 'discord.js'; + +@ApplyOptions<Command.Options>({ + name: 'example', + description: 'An example slash command demonstrating standard architecture', + preconditions: ['isCommandDisabled'] +}) +export class ExampleCommand extends Command { + public override registerApplicationCommands(registry: Command.Registry) { + registry.registerChatInputCommand(builder => + builder + .setName(this.name) + .setDescription(this.description) + .addStringOption(option => + option + .setName('message') + .setDescription('Message to echo') + .setRequired(false) + ) + ); + } + + public override async chatInputRun(interaction: ChatInputCommandInteraction) { + const message = interaction.options.getString('message') || 'Hello World!'; + + return interaction.reply({ + content: `Echo: **${message}**`, + ephemeral: true + }); + } +} +``` + +> [!NOTE] +> Always include the `isCommandDisabled` precondition. This enables guild administrators to disable or enable individual slash commands from the dashboard or via configuration. + +--- + +## 🧩 Modular Option Submodules Pattern + +To comply with Discord's command limits and keep commands maintainable, we consolidate related subcommands into a single command with **options**, splitting individual handlers into discrete files in `src/lib/<feature>/options/`. + +### 1. Directory Structure + +```text +apps/bot/src/lib/custom/options/ +├── registry.ts # Central option registry and execution router +├── types.ts # Option interfaces and context types +├── firstOption.ts # First option implementation +└── secondOption.ts # Second option implementation +``` + +### 2. Defining Option Handlers + +In `apps/bot/src/lib/custom/options/types.ts`: +```typescript +import type { ChatInputCommandInteraction } from 'discord.js'; + +export interface CustomOptionContext { + interaction: ChatInputCommandInteraction; + value?: string | null; +} + +export interface CustomOptionHandler { + name: string; + description: string; + execute(context: CustomOptionContext): Promise<unknown>; +} +``` + +In `apps/bot/src/lib/custom/options/firstOption.ts`: +```typescript +import type { CustomOptionHandler, CustomOptionContext } from './types'; + +export const firstOption: CustomOptionHandler = { + name: 'first', + description: 'Executes the first operation', + async execute({ interaction, value }: CustomOptionContext) { + return interaction.reply({ + content: `First option executed with value: ${value ?? 'None'}`, + ephemeral: true + }); + } +}; +``` + +In `apps/bot/src/lib/custom/options/registry.ts`: +```typescript +import type { CustomOptionHandler, CustomOptionContext } from './types'; +import { firstOption } from './firstOption'; + +export const customOptions: Record<string, CustomOptionHandler> = { + first: firstOption +}; + +export async function executeCustomOption( + optionName: string, + context: CustomOptionContext +): Promise<unknown> { + const handler = customOptions[optionName]; + if (!handler) { + return context.interaction.reply({ + content: `:x: Unknown option \`${optionName}\`.`, + ephemeral: true + }); + } + return handler.execute(context); +} +``` + +--- + +## 🎨 Standardized Rich Embeds + +All embeds should be generated through factory functions located in `apps/bot/src/lib/embeds/` to preserve unified branding, colors, and timestamps across the bot. + +```typescript +import { EmbedBuilder } from 'discord.js'; +import type { User } from 'discord.js'; + +export interface AlertEmbedOptions { + title: string; + description: string; + user?: User; + url?: string; +} + +export function createAlertEmbed(options: AlertEmbedOptions): EmbedBuilder { + const embed = new EmbedBuilder() + .setColor('#5865F2') // Unified Discord Blurple branding + .setTitle(options.title) + .setDescription(options.description) + .setTimestamp(); + + if (options.url) embed.setURL(options.url); + if (options.user) { + embed.setFooter({ + text: `Requested by ${options.user.username}`, + iconURL: options.user.displayAvatarURL() + }); + } + + return embed; +} +``` + +--- + +## 👂 Building an Event Listener + +Listeners react to Discord gateway events, interaction events, or internal Sapphire events. + +### 1. File Location +Create your listener under `apps/bot/src/listeners/<category>/<listenerName>.ts`. + +### 2. Example Listener Implementation + +```typescript +import { ApplyOptions } from '@sapphire/decorators'; +import { Listener, Events } from '@sapphire/framework'; +import type { GuildMember } from 'discord.js'; +import Logger from '../../lib/logger'; + +@ApplyOptions<Listener.Options>({ + event: Events.GuildMemberAdd +}) +export class MemberJoinListener extends Listener<typeof Events.GuildMemberAdd> { + public override async run(member: GuildMember) { + Logger.info(`[MemberJoin] User ${member.user.tag} joined ${member.guild.name}`); + + const welcomeConfig = member.client.session.guilds.get(member.guild.id)?.welcome; + if (!welcomeConfig?.enabled || !welcomeConfig.channelId) return; + + const channel = member.guild.channels.cache.get(welcomeConfig.channelId); + if (channel && 'send' in channel) { + const formattedMessage = (welcomeConfig.message || 'Welcome {user} to {server}!') + .replace('{user}', `<@${member.id}>`) + .replace('{server}', member.guild.name) + .replace('{memberCount}', String(member.guild.memberCount)); + + await channel.send({ content: formattedMessage }); + } + } +} +``` + +--- + +## 🔄 Creating Features & Background Monitors + +Master-Bot features background pollers (such as YouTube and Twitch stream alerts, and reminder schedulers). + +```mermaid +sequenceDiagram + autonumber + participant Engine as Scheduler / Loop + participant Session as SessionManager + participant API as External API / RSS + participant Discord as Discord Channels (Text/Forum) + + Engine->>Session: Get active subscriptions + Session-->>Engine: Return target channels + Engine->>API: Fetch latest streams / uploads + API-->>Engine: Return metadata + alt New Live Broadcast or Video Upload + Engine->>Discord: Send alert (Text Msg or Forum Thread) + Engine->>Session: Update lastStreamId & status + end +``` + +### Best Practices for Polling Services: +1. **Concurrency Control**: Use an `isRunning` lock boolean to prevent overlapping execution cycles if an API response is slow. +2. **Quota & Rate-Limit Preservation**: Utilize fast, free XML feeds (e.g. YouTube RSS feeds) for routine checks, saving API keys or OAuth tokens for enrichment when new items appear. +3. **Forum Channel Routing**: Always inspect `channel.type`: + - If `ChannelType.GuildForum`, create a thread with `forum.threads.create({ name, message })`. + - If standard `TextChannel`, send directly via `channel.send({ content, embeds })`. +4. **Lifecycle Hooks**: Export `startMonitor(client)` and `stopMonitor()` functions so processes are gracefully cleaned up during shutdown. + +--- + +## 🗄️ Database & State Management + +Master-Bot uses a dual database and caching tier: +- **`DB_URI`**: Automatically resolves connection strings. Defaults to zero-ops SQLite (`file:/data/db.sqlite`) with production scaling to PostgreSQL (`postgresql://...`). +- **Prisma Client**: Shared via `@master-bot/db`. Run `pnpm db:generate` to regenerate types when schemas change. +- **`SessionManager` (`client.session`)**: Provides instantaneous in-memory caching and Redis synchronization (`ioredis-mock` or external Redis). + +```typescript +// Accessing database models via Prisma: +import prisma from '@master-bot/db'; + +// Accessing live session state: +const guildData = client.session.guilds.get(guildId); +``` + +--- + +## 🧪 Writing Unit Tests with Vitest + +Master-Bot uses `@helix-origin/vitest-suite` to provide mocks for Discord clients, interactions, and Redis. + +### 1. Writing a Test Spec +Create `tests/unit/<featureName>.test.ts`: + +```typescript +import { describe, it, expect, beforeEach } from 'vitest'; +import { createMockClient } from '@helix-origin/vitest-suite'; + +describe('Custom Feature Suite', () => { + let mockClient: ReturnType<typeof createMockClient>; + + beforeEach(() => { + mockClient = createMockClient(); + }); + + it('should perform expected validation', () => { + expect(mockClient).toBeDefined(); + }); +}); +``` + +### 2. Validation Quality Gates +Always run the complete quality pipeline before submitting a pull request: + +```bash +# 1. Run unit test suite +pnpm test + +# 2. Type-check all workspace packages +pnpm type-check + +# 3. Verify linting and workspace boundary rules +pnpm lint +``` + +--- + +## 🔗 Related Guides +- [Home](Home) — Return to wiki main page +- [Architecture](Architecture) — Deep dive into system architecture and data flows +- [Commands Reference](Commands) — Full breakdown of existing slash commands +- [Stream Alerts](Reminders-and-Twitch) — YouTube and Twitch notification configuration +- [Deployment](Deployment) — Production self-hosting and deployment guide + +--- +[Home](Home) • [Documentation Index](Home) • [GitHub Repository](https://github.com/galnir/Master-Bot) diff --git a/wiki/FAQ.md b/wiki/FAQ.md new file mode 100644 index 000000000..20724d586 --- /dev/null +++ b/wiki/FAQ.md @@ -0,0 +1,59 @@ +# ❓ FAQ & Troubleshooting + +## 🤖 The Bot + +**The bot doesn't respond to any commands.** +- The bot must have **Read Messages / Send Messages** and `applications.commands` permission in the server, and you must **re-invite** it with the new scope if it was added without slash permissions. +- It may still be starting: check `logs/bot.log`. Slash commands register after the first successful connection. +- Verify `DISCORD_TOKEN` in `.env` and that the app didn't fail on a missing/optional API key. + +**Slash commands are missing entirely.** +Re-invite the bot with the `applications.commands` OAuth scope alongside `bot` (see [Getting Started](Getting-Started#discord-application-setup)). + +**Commands from a disabled module still show.** +Register happens at boot — restart the bot after flipping a feature flag (`LAVA_ENABLED`, `GIFS_ENABLED`, `TWITCH_ENABLED`, `NEWS_ENABLED`, `IGDB_ENABLED`). + +**Where are the logs?** `logs/` at the workspace root — `bot.log`, `dashboard.log`, `lavalink.log`, and `combined.log`. + +## 🎵 Music + +**“No available audio players” / nothing plays.** +Lavalink isn't running or isn't reachable. Start it with `java -jar Lavalink.jar`, via the docker-compose `lavalink` container, or connect to your external self-hosted Lavalink server. Verify that `LAVA_HOST`, `LAVA_PORT`, `LAVA_PASS`, and `LAVA_SECURE` all match your setup. On an external instance also set `LAVA_EXTERNAL=true`. + +**Spotify links do nothing.** Add `SPOTIFY_CLIENT_ID` + `SPOTIFY_CLIENT_SECRET`. + +**YouTube throttles or blocks playback.** Complete YouTube OAuth device authorization when the embedded Lavalink server prompts on startup, or supply `YOUTUBE_REFRESH_TOKEN` in `.env` (see [Music & Lavalink](Music#youtube-oauth-embedded-server-startup)). + +**Playback works but there's no visual progress / embed.** The now-playing embed needs **Embed Links** permission in the channel. + +## 🗄️ Data & Database + +**Where do settings, playlists, and reminders live?** In SQLite (`/data/db.sqlite`) or PostgreSQL (`DB_URI`) — created automatically. Settings take effect after the bot hydrates at boot; save them and restart if something looks stale. + +**I want a clean slate.** Stop the bot, delete `/data/db.sqlite`, run `pnpm db:push`, and restart. (The file is recreated on next boot.) + +**SQLite errors like “database is locked” appear.** This usually means the bot/dashboard processes are pointing at different copies of the file, or a long-running transaction. Ensure both processes share the same directory/volume and aren't duplicated. + +**How do I back up?** Copy `/data/db.sqlite` (ideally via `sqlite3 .backup`). See [Deployment](Deployment#backups--maintenance). + +## 🔐 Auth & Dashboard + +**Dashboard shows “Authorization failed”.** Confirm `DISCORD_CLIENT_ID` / `DISCORD_CLIENT_SECRET` and that `NEXTAUTH_URL` matches the URL you're visiting (localhost vs. domain — and HTTP vs. HTTPS). + +**Dashboard shows no servers.** The bot must be a member of the server and have completed at least one boot there; also check the invite wasn't restricted by server settings. + +**Users can't sign in.** The Discord OAuth **Redirect URI** must be `<NEXTAUTH_URL>/api/auth/callback/discord` in the Developer Portal. + +**Where do dashboard settings go?** The same SQLite DB the bot uses — both processes must share one `db.sqlite`. + +## 🛠️ Build & Runtime + +**`pnpm install` fails.** You need Node `>=20` and pnpm `8.x`. If you're behind a proxy, adjust the pnpm registry. + +**“Cannot find module @master-bot/...”** The workspace wasn't installed/built — run `pnpm install` then `pnpm build` from the repo root. + +**Port 3000 already in use.** Change `PORT` and set `NEXTAUTH_URL` to the new port, or stop whatever is occupying it. + +## 🤔 Still stuck? + +Open an issue at https://github.com/galnir/Master-Bot/issues with the relevant `logs/` output and your `.env` **values masked** — never post secrets. \ No newline at end of file diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md new file mode 100644 index 000000000..71f0e3cd9 --- /dev/null +++ b/wiki/Getting-Started.md @@ -0,0 +1,164 @@ +# 🚀 Getting Started with Master-Bot + +This guide walks you through system prerequisites, installation, environment configuration, and launching **Master-Bot** for local development and self-hosting. + +--- + +## 📑 Table of Contents +1. [Prerequisites](#-prerequisites) +2. [Step-by-Step Installation](#-step-by-step-installation) +3. [Discord Application Setup](#-discord-application-setup) +4. [Environment Configuration](#-environment-configuration) +5. [Launching the Stack](#-launching-the-stack) +6. [Testing & Quality Verification](#-testing--quality-verification) +7. [Where Data Lives](#-where-data-lives) +8. [Related Guides](#-related-guides) + +--- + +## ✅ Prerequisites + +Ensure your host environment meets the following specifications: + +| Requirement | Supported Version | Purpose | +| :--- | :--- | :--- | +| **Node.js** | `>= 20.0.0` (v24 LTS recommended) | JavaScript/TypeScript runtime for bot and dashboard. | +| **pnpm** | `>= 8.0.0` (repository pins `pnpm@8.6.7`) | Fast, disk-efficient package manager. | +| **Java** | `Java 17+` (Adoptium / Temurin 21 recommended) | Only required if running a local Lavalink v4 audio engine. | +| **Discord App** | Developer Portal Account | Bot token, client ID, and secret. | + +> [!TIP] +> Master-Bot operates with zero external database dependencies for local setups. SQLite persistence and in-memory Redis caching (`ioredis-mock`) are initialized automatically. + +--- + +## 📦 Step-by-Step Installation + +```mermaid +flowchart LR + Clone[1. Clone Repo] --> Install[2. pnpm install] + Install --> DBInit[Auto: Prepare Schema & /data/db.sqlite] + DBInit --> Config[3. Configure .env] + Config --> Run[4. pnpm dev] +``` + +### 1. Clone the repository +```bash +git clone https://github.com/galnir/Master-Bot.git +cd Master-Bot +``` + +### 2. Install dependencies & initialize database +```bash +pnpm install +``` + +`pnpm install` automatically runs the database bootstrap hook: +- Compiles the Prisma schema for SQLite. +- Creates the `/data` directory if missing. +- Pushes the database schema directly to `/data/db.sqlite`. + +--- + +## 🔐 Discord Application Setup + +1. Open the [Discord Developer Portal](https://discord.com/developers/applications) and click **New Application**. +2. Under the **Bot** tab: + - Click **Reset Token** and save your `DISCORD_TOKEN`. + - Enable **Privileged Gateway Intents**: + - ✅ **Presence Intent** + - ✅ **Server Members Intent** + - ✅ **Message Content Intent** +3. Under the **OAuth2 → General** tab: + - Copy your **Client ID** (`DISCORD_CLIENT_ID`) and **Client Secret** (`DISCORD_CLIENT_SECRET`). + - Add your redirect callback: `https://your-domain.com/api/auth/callback/discord` (or `http://localhost:3000/api/auth/callback/discord` for local dev). +4. Under **OAuth2 → URL Generator**: + - Select scopes: `bot` and `applications.commands`. + - Select permissions: `Administrator` (recommended for full feature suite, or standard moderation and voice permissions). + +--- + +## ⚙️ Environment Configuration + +Copy `.env.example` to `.env`: + +```bash +cp .env.example .env +``` + +Populate your mandatory configuration settings: + +```env +# Database (URI string: SQLite stored at /data/db.sqlite, or external PostgreSQL) +DB_URI="file:/data/db.sqlite" + +# Dashboard URLs +# INTERNAL_URL binds to 0.0.0.0:3000 to listen on all interfaces, allowing public connections +INTERNAL_URL="0.0.0.0:3000" +PUBLIC_URL="http://localhost:3000" +DISCORD_CALLBACK_URL="https://discord.com/api/oauth2/authorize?client_id=YOUR_CLIENT_ID&permissions=8&scope=bot%20applications.commands" + +# Discord Bot Credentials +DISCORD_TOKEN="your-discord-bot-token" +DISCORD_CLIENT_ID="your-client-id" +DISCORD_CLIENT_SECRET="your-client-secret" +NEXTAUTH_SECRET="your-random-32-char-secret" + +# Audio & Lavalink +LAVA_ENABLED=true +LAVA_EXTERNAL=false +``` + +--- + +## 🚀 Running the Application + +### Development Mode (Hot-Reloading) +```bash +pnpm dev +``` +- Launches the Sapphire Discord bot with `tsx watch`. +- Launches the Next.js 15 Web Dashboard at `http://localhost:3000`. +- Automatically initializes the embedded Lavalink v4 engine if Java 17+ is installed. + +### Production Build & Launch +```bash +# Compile all TypeScript apps and packages: +pnpm build + +# Start the unified production cluster: +pnpm start +``` + +### Quality Assurance & Validation +```bash +# Run Vitest test suite: +pnpm test + +# Verify TypeScript compilation across monorepo: +pnpm type-check + +# Run linter and boundary validation: +pnpm lint +``` + +--- + +## 🗃️ Where Data Lives + +- **Database**: `/data/db.sqlite` — Single persistent SQLite database file (or external PostgreSQL database). +- **In-Memory Cache**: `ioredis-mock` runs in-process; connects to external Redis if `REDIS_URL` is set. +- **Log Files**: `logs/` — Process logs for debugging and telemetry. + +--- + +## 🔗 Related Guides +- [Home](Home) — Return to wiki main page +- [Configuration Reference](Configuration) — Detailed explanation of all `.env` options +- [Architecture](Architecture) — System design and data layer deep dive +- [Commands Reference](Commands) — Slash commands and `/set` options +- [Developer Guide](Development-Guide) — How to add new commands and listeners +- [Deployment](Deployment) — Production VPS and Docker deployment guide + +--- +[Home](Home) • [Documentation Index](Home) • [GitHub Repository](https://github.com/galnir/Master-Bot) \ No newline at end of file diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 000000000..29641531a --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,88 @@ +# 🤖 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) + +**Master-Bot** is an enterprise-grade, unified Discord music, moderation, and utility bot with an embedded **Next.js 15 Web Dashboard**. Built with **TypeScript**, **Sapphire Framework**, **discord.js v14**, **Prisma ORM** (supporting external PostgreSQL with zero-ops SQLite fallback), in-memory/external Redis caching, and an embedded **Lavalink v4** audio server powered by [`@helix-origin/lavalink-server`](https://github.com/HELIX-Origin/Lavalink-Server). + +--- + +## 🏗️ System Architecture + +```mermaid +flowchart TD + subgraph Clients [Clients & Web Endpoints] + DiscordGateway[Discord API Gateway] + BrowserClients[Web Dashboard Users] + VoiceGateway[Discord Voice WebSockets] + end + + subgraph CoreService [Master-Bot Unified Service :3000] + WebServer[Internal HTTP / SSR Web Server] + BotClient[Sapphire Discord Client] + DashboardApp[Next.js 15 App Router & tRPC v11] + EmbeddedLavalink[Embedded Lavalink Server @helix-origin/lavalink-server] + SessionMgr[In-Memory SessionManager] + end + + subgraph DataStorage [Storage & Cache Tier with Fallback] + subgraph DatabaseTier [Database] + PG[(PostgreSQL External)] + SQLite[(SQLite: /data/db.sqlite Fallback)] + end + subgraph CacheTier [Cache / Session State] + ExtRedis[(Redis External)] + MockRedis[ioredis-mock In-Process Fallback] + end + end + + DiscordGateway <--> BotClient + BrowserClients <--> WebServer + WebServer <--> DashboardApp + DashboardApp <--> BotClient + BotClient <--> SessionMgr + SessionMgr <--> DataStorage + BotClient <--> EmbeddedLavalink + VoiceGateway <--> EmbeddedLavalink +``` + +--- + +## ⚡ Key Modernization Features + +- **🎵 Embedded High-Fidelity Audio:** Features an embedded Lavalink v4 server from [`@helix-origin/lavalink-server`](https://github.com/HELIX-Origin/Lavalink-Server), with YouTube OAuth support, Spotify metadata resolution (`lavasrc-plugin`), SoundCloud, and DSP audio filters (`/bassboost`, `/nightcore`, `/vaporwave`, `/karaoke`). Supports instant external node connection via `LAVA_EXTERNAL=true`. +- **🗄️ Dual Database Architecture:** Native support for external PostgreSQL (`DB_URI=postgresql://...`) with automatic, zero-configuration local fallback to SQLite (`file:/data/db.sqlite`). +- **⚡ Dual Cache Architecture:** Seamlessly connects to external Redis instances (`REDIS_URL`) while automatically falling back to in-memory `ioredis-mock` if Redis is unconfigured or unreachable. +- **🧪 Universal Testing Suite:** Preconfigured Vitest monorepo testing suite powered by [`@helix-origin/vitest-suite`](https://github.com/HELIX-Origin/vitest-suite) with dedicated Discord.js and Redis test doubles. +- **🌐 Next.js 15 Web Dashboard:** Modern command center with 9 feature studios (Guild Management, Music Studio, Broadcaster, Audit Logs, Ticket Hub, Reminders, Command Controls, Welcome Greetings, and Telemetry). +- **🤖 Autonomous Agent Ecosystem:** Complete [`.agents/`](../.agents) directory comprising specialized agents, production skills (including GitHub CLI and issue orchestration with Mermaid diagrams), standardized rules, and templates. +- **🚀 Low-Cost VPS & Docker Self-Hosting:** Production-ready guides for budget-friendly VPS providers (Hetzner, OVHcloud, DigitalOcean, Linode, Vultr, Contabo) with persistent SQLite storage, unthrottled networking, and optional Heroku cloud support. + +--- + +## 🤖 Autonomous Agent Ecosystem + +The repository is maintained and enhanced with a standardized multi-agent operating system defined in [`.agents/`](../.agents) and indexed in [`AGENTS.md`](../AGENTS.md): + +- 🐙 **GitHub CLI Expert (`gh-cli-expert`)**: Full command automation for issues, PRs, runs, and releases. +- 📋 **Issue Orchestrator (`issue-orchestrator`)**: Creates human-readable issues with emojis, Mermaid diagrams, and separate child sub-issues. +- 📚 **Wiki Management (`wiki-management`)**: Enforces relative wiki links, sidebar hierarchy, and navigation footers. +- 🗄️ **Database Fallback Manager (`database-fallback`)**: Manages PostgreSQL/SQLite and Redis/`ioredis-mock` lifecycle. + +--- + +## 📖 Continue Reading + +- Want to run the bot? → [**Getting Started**](Getting-Started) +- Deep dive into components? → [**Architecture**](Architecture) +- Complete slash command catalog? → [**Commands Reference**](Commands) +- Reminders & stream alerts setup? → [**Reminders & Stream Alerts**](Reminders-and-Twitch) +- Configuring audio and plugins? → [**Music & Audio**](Music) +- Web dashboard guide? → [**Web Dashboard**](Dashboard) +- Contributing & extending the bot? → [**Developer's Guide**](Development-Guide) +- Production rollout? → [**Deployment**](Deployment) +- Common questions? → [**FAQ & Troubleshooting**](FAQ) +- Discord verification & legal policies? → [**Privacy Policy**](../PRIVACY.md) & [**Terms of Service**](../TOS.md) \ No newline at end of file diff --git a/wiki/Lavalink.md b/wiki/Lavalink.md new file mode 100644 index 000000000..bdb452080 --- /dev/null +++ b/wiki/Lavalink.md @@ -0,0 +1,72 @@ +# 🎵 Lavalink v4 Configuration (`application.yml`) + +Master-Bot runs **Lavalink v4** with a **custom configuration** tracked in the repo as [`application.yml.example`](../application.yml.example). This is **not** Lavalink's stock config — it ships fixes for features that are broken or unusable with the default `application.yml` from the Lavalink release. + +> ⚠️ **Always use our template, never Lavalink's default.** +> The bare `application.yml` in each [Lavalink release](https://github.com/lavalink-devs/Lavalink) is minimal and will **not** play YouTube (throttled/blocked, single client) and will **not** resolve Spotify links. Always start from our example: +> +> ```bash +> cp application.yml.example application.yml +> ``` +> +> The Docker setup and local scripts do this for you automatically — see [Deployment](Deployment). + +## What our config fixes + +| Area | Stock Lavalink default | Master-Bot's `application.yml.example` | +| --- | --- | --- | +| YouTube playback | single client, frequently throttled or age-locked | `youtube-plugin` with six rotating clients (`TV`, `MUSIC`, `ANDROID_VR`, `IOS`, `WEB`, `WEBEMBEDDED`), a `remoteCipher` resolver (`cipher.kikkia.dev`) and optional [YouTube OAuth](Music#youtube-oauth-embedded-server-startup) | +| YouTube source | native `youtube` source is unreliable | native source disabled (`youtube: false`); playback handled by the plugin instead | +| Spotify resolution | not resolvable at all | `lavasrc-plugin` with ISRC providers (`ytmsearch` / `ytsearch` by `%ISRC%`) so tracks, albums and playlists map to their YouTube counterparts | +| Streaming quality | default buffering | tuned `bufferDurationMs: 400`, `frameBufferDurationMs: 10000`, `opusEncodingQuality: 10`, `resamplingQuality: HIGH` | +| Stalled tracks | 15s stuck threshold | `trackStuckThresholdMs: 30000` — fewer false "stuck" skips | + +## Plugins it loads + +Two plugins are pinned under `lavalink.plugins`: + +- **`dev.lavalink.youtube:youtube-plugin`** `1.18.2` — from `https://maven.lavalink.dev/releases` +- **`com.github.topi314.lavasrc:lavasrc-plugin`** `4.8.3` — from `https://maven.topi.wtf/releases` + +Lavalink downloads these artifacts (plus the plugin's YouTube clients) from Maven on first boot, so the server needs outbound internet access at startup. + +## Environment variables + +`application.yml` expands these at boot; provide them via `.env`: + +| Variable | Used for | Default | +| --- | --- | --- | +| `LAVA_PASS` | WebSocket server password | `youshallnotpass` | +| `LAVA_PORT` | HTTP/WebSocket port | `2333` | +| `PORT` | overrides `LAVA_PORT` when set (see below) | — | +| `YOUTUBE_CIPHER_URL` | remote cipher resolver for YouTube | `https://cipher.kikkia.dev/` | +| `YOUTUBE_CIPHER_PASSWORD` | cipher resolver password | empty | +| `YOUTUBE_REFRESH_TOKEN` | [YouTube OAuth](Music#youtube-oauth-embedded-server-startup) refresh token | empty | +| `YOUTUBE_SKIP_INIT` | skip YouTube OAuth initialization | `false` | +| `SPOTIFY_CLIENT_ID` | Spotify → YouTube resolution | empty | +| `SPOTIFY_CLIENT_SECRET` | Spotify → YouTube resolution | empty | + +> 💡 **Why `PORT` matters:** the template binds `server.port` to `${PORT:${LAVA_PORT:2333}}`. On hosts that inject a `PORT` value, set `PORT`/`LAVA_PORT` so audio stays on the port your bot expects; locally it just defaults to `2333`. + +## Where this config is used + +| Setup | How `application.yml` appears | +| --- | --- | +| **Local dev / VPS** | `pnpm dev` auto-starts Lavalink with the repo config when Java 17+ is detected (see [Music & Lavalink](Music#option-b-local-lavalink-server-for-local--dedicated-development)). | +| **External server** | Your external Lavalink server must be configured with this file as `application.yml`. | +| **Docker / VPS compose** | `docker-compose.yml` mounts the repo and starts Lavalink from our config in its own container. | + +## Updating plugin versions + +Both versions are edited directly in `application.yml.example`. Check the release feeds before bumping: + +- youtube-plugin: <https://github.com/lavalink-devs/youtube-source/releases> +- lavasrc: <https://github.com/topi314/LavaSrc/releases> + +After changing the config, restart Lavalink so it re-downloads the plugin artifacts on boot. + +## Related pages + +- [Music & Lavalink](Music) — setup options and playback +- [Deployment](Deployment) — Docker/VPS + local deployment +- [FAQ & Troubleshooting](FAQ) \ No newline at end of file diff --git a/wiki/Moderation.md b/wiki/Moderation.md new file mode 100644 index 000000000..eb2ed77c6 --- /dev/null +++ b/wiki/Moderation.md @@ -0,0 +1,69 @@ +# 🔨 Moderation & Audit Logging + +Master-Bot includes a tight moderation suite and a granular, event-driven audit log. + +## ⚖️ Moderation Commands + +| Command | What it does | +| --- | --- | +| `/ban <user> [reason]` | Bans a member by mention/ID, optionally with a reason. | +| `/kick <user> [reason]` | Kicks a member from the server. | +| `/timeout <user> <duration> [reason]` | Places a member in timeout for a duration. | +| `/slowmode <duration>` | Sets the current channel's slowmode. | +| `/purge <amount>` | Bulk-deletes up to 100 messages in the current channel. | + +### Permission & Hierarchy Safety + +Every moderation action validates the target before executing: + +- The target cannot be the **guild owner**. +- The target cannot be the **bot** itself. +- The target's **highest role must rank below the invoker's** highest role — you can't punish someone at or above your own authority level. + +Failures are reported back to the invoker; successful actions return a confirmation embed with the target and reason. + +```mermaid +flowchart TD + A["/ban @user"] --> B{"Is target guild owner?"} + B -->|yes| X["🚫 Rejected"] + B -->|no| C{"Is target the bot?"} + C -->|yes| X + C -->|no| D{"Target role ≥ invoker role?"} + D -->|yes| X + D -->|no| E["Execute ban + optional reason"] + E --> F["Log to audit channel (mod_ban)"] +``` + +## 📜 Audit Logging + +When a **log channel** is configured (`/set logging set-channel`), the bot dispatches embeds for **20 distinct server events** across six categories. Each trigger can be enabled or disabled independently from the dashboard's **Audit Log studio** or `/set`. + +```mermaid +flowchart LR + EV["Discord Event"] --> T{"Trigger subscribed?"} + T -->|no| IGN["Ignored"] + T -->|yes| EMB["Build log embed<br/>(before/after, author, channel)"] + EMB --> CH["#audit-log channel"] +``` + +| Category | Event IDs | What gets logged | +| --- | --- | --- | +| 👥 **Member Events** | `member_join`, `member_leave`, `member_role`, `member_nick` | Joins (with account age + member count), leaves/kicks, role add/remove, nickname changes. | +| 💬 **Message Events** | `message_delete`, `message_edit`, `message_purge` | Deleted content + attachments, before/after edits, bulk purges. | +| 📁 **Channel Events** | `channel_create`, `channel_delete`, `channel_update` | Channel creation, deletion, rename/topic/permission edits. | +| 🛡️ **Role Events** | `role_create`, `role_delete`, `role_update` | Role creation, deletion, name/color/permission changes. | +| 🔊 **Voice Events** | `voice_join`, `voice_leave`, `voice_move` | Member voice joins, leaves, and channel switches. | +| ⚖️ **Moderation Actions** | `mod_ban`, `mod_unban`, `mod_timeout`, `mod_kick` | Staff-executed punishments. | + +### Setting Up + +```bash +/set logging set-channel #channel # pick the log channel +/set logging toggle-log-channel # enable it (or disable) +``` + +All 20 triggers default to **enabled** the first time logging turns on — trim them per-category from the dashboard's **Audit Log** studio (`log-events-form`), which shows live counts (`X / 20 active`) with Enable-all / Disable-all shortcuts. + +### Storage & Retention + +Audit settings (`logChannel`, `logEvents`, `logChannelEnabled`) persist per guild in SQLite via the session layer — they survive reboots and are immediately available to the dashboard. \ No newline at end of file diff --git a/wiki/Music.md b/wiki/Music.md new file mode 100644 index 000000000..5737c2bd4 --- /dev/null +++ b/wiki/Music.md @@ -0,0 +1,121 @@ +# 🎵 Music & Lavalink + +Master-Bot's audio engine is powered by **Lavalink v4** — a standalone, high-performance audio server that streams and mixes audio, letting the bot stay lightweight. The bot itself never touches raw audio packets. + +## 🔊 How Playback Works + +```mermaid +flowchart LR + U["User<br/>/play Search"] + Q["QueueClient<br/>(lavalink-client)"] + LV["Lavalink v4<br/>(audio server)"] + YT["YouTube<br/>Spotify<br/>SoundCloud<br/>Twitch<br/>Vimeo"] + PL["Player Embed<br/>(now playing)"] + QS["Queue Store<br/>(state + Lua ops)"] + + U --> Q + Q --> YT + YT -->|"track resolved"| Q + Q --> LV + LV -->|"audio streamed"| VC["Voice Channel"] + Q --> PL + Q --> QS +``` + +- **`lavalink-client`** (`QueueClient`) negotiates WebSocket sessions with Lavalink and wraps the player API. +- **Queue/QueueStore** manage the guild queue, shuffle, removal, and position moves (fast list primitives via Redis-backed Lua scripts; state caches optionally in Redis). +- **`searchSong`** resolves queries/URLs across sources; the **`lavasrc-plugin`** resolves Spotify tracks, albums, and playlists to their YouTube counterparts. + +## 🗄️ Lavalink Setup + +Master-Bot supports self-hosted Lavalink setups: + +### Option A: Private / Self-Hosted External Lavalink (Recommended for VPS) + +Run Lavalink v4 on a dedicated VPS or Docker container you control (such as [HELIX-Origin/Lavalink-Server](https://github.com/HELIX-Origin/Lavalink-Server)), and point the bot at it. The bot process itself runs no audio code, so resource usage stays low. + +In `.env`: + +```env +LAVA_ENABLED=true +LAVA_EXTERNAL=true +LAVA_HOST=your-lavalink-host.com +LAVA_PORT=443 +LAVA_PASS=youshallnotpass +LAVA_SECURE=true +``` + +> ℹ️ **Running without music:** if your Lavalink server is offline, set `LAVA_ENABLED=false` — the bot and dashboard keep running normally, only music commands are disabled. + +### Option B: Local Lavalink Server (For Local / Dedicated Development) +1. Download the latest **Lavalink v4** jar from the [Lavalink releases page](https://github.com/lavalink-devs/Lavalink/releases). +2. Copy the **repo's** config — `cp application.yml.example application.yml` (do **not** use the stock `application.yml` from the Lavalink release; it's missing the fixes below — see [Lavalink Configuration](Lavalink)). +3. Launch the server: `java -jar Lavalink.jar` + +The config template (`application.yml.example`) ships with: + +| Setting | Value | +| --- | --- | +| Server port | `${PORT:${LAVA_PORT:2333}}` | +| Server password | `${LAVA_PASS:youshallnotpass}` | +| `youtubePlugin` | `1.18.2` | +| `lavasrcPlugin` | `4.8.3` | +| YouTube resolver | plugin with `remoteCipher` (`${YOUTUBE_CIPHER_URL:https://cipher.kikkia.dev/}`) and multi-client rotation: `TV`, `MUSIC`, `ANDROID_VR`, `IOS`, `WEB`, `WEBEMBEDDED` | +| Native sources | `youtube: false` (handled by the plugin instead), Spotify/Local enabled | + +> 💡 **Tip:** When `LAVA_EXTERNAL=false` and `LAVA_ENABLED=true`, `pnpm dev` will auto-launch a local Lavalink server if Java 17+ is detected on your system. + +## ▶️ Playing & the Player Embed + +- `/play <query|url>` — play something fast. Comma-separated URLs are supported (`/play url1, url2`). +- The **now-playing embed** updates live with the track title, author, thumbnail, duration, and an animated progress bar, plus queue position/page hints. +- Player controls live on the embed (pause/stop/skip buttons via `buttonsCollector`); `/jump`, `/seek`, `/shuffle`, `/remove`, `/move`, `/volume` adjust the queue. +- When nobody is left in the voice channel the bot auto-disconnects (`voiceStateUpdate` listener). + +## 🎛️ DSP Filters + +Real-time audio effects applied server-wide, toggled per command: + +| Effect | What it does | +| --- | --- | +| `/bassboost` | Boosts low frequencies. | +| `/karaoke` | Attenuates the vocal band (sing along!). | +| `/nightcore` | Pitch + tempo up. | +| `/vaporwave` | Pitch + tempo down with reverb. | + +## 💿 Custom Playlists + +Playlists are **per user and per server** — playlist names collide safely across guilds (unique on `userId + guildId + name`). + +| Command | Purpose | +| --- | --- | +| `/create-playlist <name>` | Create an empty playlist for this server. | +| `/save-to-playlist <name>` | Add the current track. | +| `/remove-from-playlist <name> <title>` | Drop a specific track. | +| `/my-playlists` | List your playlists on this server. | +| `/display-playlist <name>` | View tracks (paginated). | +| `/delete-playlist <name>` | Delete it. | + +Playlists and their songs are persisted in the database (`Playlist` / `Song` models, cascade-deleted with the owning user's membership). + +## 🎙️ Music Trivia + +`/music-trivia` starts a guess-the-song game built from the **artists currently in the queue**. Tracks play with the title/artist clues hidden while players answer; `/stop-trivia` ends it and posts the leaderboard. Powered by the bot's own trivia samples (`triviaSongs`/`triviaMatcher`). + +## 🔐 YouTube OAuth (Embedded Server Startup) + +Public YouTube playback is throttled and can be blocked. The embedded Lavalink server streams through an **authorized YouTube account** instead: + +1. On bot startup, the embedded Lavalink server checks for `YOUTUBE_REFRESH_TOKEN` (or `.youtube-oauth.json`). +2. If OAuth is configured (`YOUTUBE_CLIENT_ID`) and no token exists, the embedded server awaits device code authorization on the terminal before opening the audio gateway. +3. Once authorized, the refresh token is saved automatically to `.youtube-oauth.json` and reused across restarts without manual intervention. + +## 🧰 Troubleshooting + +| Symptom | Fix | +| --- | --- | +| `/play` errors "no available audio players" | Lavalink not running — start it, or ensure `LAVA_ENABLED=true` + correct `LAVA_PORT`/`LAVA_PASS`. | +| Spotify links don't resolve | Add `SPOTIFY_CLIENT_ID` + `SPOTIFY_CLIENT_SECRET`. | +| YouTube throttled/blocked | Complete [YouTube OAuth](#youtube-oauth). | +| No sound on remote host | `LAVA_EXTERNAL=true` (+ `LAVA_SECURE=true` if TLS). Verify the instance is reachable on port 2333. | +| Static/none playback after IP ban | Restart Lavalink with a different YouTube client via `application.yml` clients list. | \ No newline at end of file diff --git a/wiki/Reminders-and-Twitch.md b/wiki/Reminders-and-Twitch.md new file mode 100644 index 000000000..bc2ed7fad --- /dev/null +++ b/wiki/Reminders-and-Twitch.md @@ -0,0 +1,165 @@ +# ⏰ Reminders & Stream Alerts (YouTube & Twitch) + +Master-Bot features background notification systems that monitor **scheduled reminders**, **Twitch live broadcasts**, and **YouTube video uploads & live streams**, routing notifications to text channels or creating organized threads in Discord Forum channels. + +--- + +## 📑 Table of Contents +1. [⏰ Scheduled Reminders](#-scheduled-reminders) +2. [📺 YouTube Alerts (Streams & Uploads)](#-youtube-alerts-streams--uploads) +3. [🟣 Twitch Live Stream Alerts](#-twitch-live-stream-alerts) +4. [Forum vs. Text Channel Delivery](#-forum-vs-text-channel-delivery) +5. [Related Guides](#-related-guides) + +--- + +## ⏰ Scheduled Reminders + +The `/reminder` command schedules one-off or recurring reminders delivered directly via **Direct Message (DM)** to the requesting member. + +### 💻 Usage + +```text +/reminder description: "Server maintenance" dateTime: "2026-10-01 14:00" [repeat: "daily"] [event: "Ops"] +``` + +The bot sends a direct message at the scheduled timestamp containing your description and event tag. + +### 📝 Template Placeholders + +Reminder descriptions support dynamic interpolation placeholders: + +| Placeholder | Replaced with | +| :--- | :--- | +| `{user}` / `{mention}` / `{username}` | The requesting user's mention or name. | +| `{event}` | The event name or category specified in the command. | +| `{date}` / `{time}` | The formatted date or time of the reminder. | +| `{countdown}` / `{relative}` | Dynamic relative time until the reminder fires. | +| `{timestamp}` | Discord relative timestamp (`<t:TIMESTAMP:R>`). | + +### 📐 Scheduler Lifecycle & Architecture + +```mermaid +flowchart TD + S["ReminderManager.start()"] --> T["Tick interval (every 30s)"] + T --> D{"Is reminder due now?"} + D -->|no| T + D -->|yes| F["Format message with dynamic placeholders"] + F --> SEND["Dispatch Direct Message to User"] + SEND --> R{"Is reminder recurring?"} + R -->|yes| N["Calculate and store next due timestamp"] + R -->|no| DEL["Delete reminder from database"] +``` + +- **Persistence**: Reminders are scoped per-server (`Reminder.guildId`) and stored in the database (`DB_URI`), persisting across bot restarts. +- **Cleanup**: If a member leaves a guild, their associated reminders for that guild are automatically cleaned up. + +--- + +## 📺 YouTube Alerts (Streams & Uploads) + +Master-Bot continuously monitors YouTube channels for new video uploads and active live streams without consuming external API quota for standard polling. + +### Key Capabilities: +- **Fast Zero-Quota RSS Ingestion**: Parses channel Atom/RSS feeds for instant upload detection. +- **Live Broadcast Tracking**: Enriches live broadcast status with viewer counts and custom banners via OAuth. +- **Granular Filter Options**: Choose to receive `all` alerts, `streams` only, or `uploads` only. +- **Discord Forum Channels**: Automatically creates dedicated discussion threads for each video or stream. + +### ⚙️ Managing YouTube Subscriptions + +Use the consolidated `/set` command to configure YouTube subscriptions: + +```text +# Subscribe to all alerts (streams and uploads) in a feed channel: +/set option: youtube-add value: @MKBHD channel: #videos alerts: all + +# Subscribe only to live streams: +/set option: youtube-add value: @LofiGirl channel: #live-streams alerts: streams + +# Subscribe only to video uploads: +/set option: youtube-add value: @Veritasium channel: #science alerts: uploads + +# View all active YouTube subscriptions in the guild: +/set option: youtube-list + +# Remove a channel subscription: +/set option: youtube-remove value: @MKBHD channel: #videos +``` + +#### Accepted Channel Formats: +- **Handle**: `@MrBeast` +- **Full Channel URL**: `https://www.youtube.com/@MrBeast` +- **Legacy URL**: `https://www.youtube.com/channel/UCX6OQ3DkcsbYNE6H8uQQuVA` +- **Direct Channel ID**: `UCX6OQ3DkcsbYNE6H8uQQuVA` + +### 📐 YouTube Monitor Architecture + +```mermaid +flowchart TD + YT["YouTube Monitor (notifyYouTube.ts)"] --> FEED["Fetch Channel Feed & OAuth Status"] + FEED --> UPLOAD{"New upload detected?"} + UPLOAD -->|yes| EU["Generate Upload Embed (author, title, thumbnail)"] + EU --> DISP["Dispatch to Discord (Text Channel or Forum Thread)"] + FEED --> LIVE{"Active Live Stream detected?"} + LIVE -->|yes| EL["Generate 🔴 Live Embed (streamer, title, banner)"] + EL --> DISP +``` + +--- + +## 🟣 Twitch Live Stream Alerts + +Monitors live broadcast states for Twitch streamers and posts rich embeds with game title, live viewer count, and stream preview thumbnails when they go live. + +### ⚙️ Managing Twitch Subscriptions + +```text +# Subscribe to live alerts for a Twitch streamer: +/set option: twitch-add value: shroud channel: #streams + +# List all monitored Twitch streamers: +/set option: twitch-list + +# Remove a Twitch streamer subscription: +/set option: twitch-remove value: shroud channel: #streams + +# Check live broadcast status on demand: +/twitch-status streamer: shroud +``` + +### 📐 Twitch Monitor Flow + +```mermaid +flowchart LR + M["Twitch Monitor (notifyChannels.ts)"] --> API["Twitch API (Helix App Token)"] + API --> S{"Streamer live & unannounced?"} + S -->|yes| E["Create Live Embed: Game, Title, Viewers"] + E --> C["Post to Text Channel or create Forum Thread"] + S -->|no| W["Wait for next poll cycle"] +``` + +--- + +## 💬 Forum vs. Text Channel Delivery + +Master-Bot dynamically adapts alert delivery based on Discord channel type: + +| Destination Channel | Delivery Method | Behavior | +| :--- | :--- | :--- | +| **Standard Text Channel** (`GuildText`) | Direct Channel Message | Posts a styled embed with notification text. | +| **Forum Channel** (`GuildForum`) | Forum Thread Post | Automatically creates a new forum thread titled after the stream or video, allowing members to discuss the broadcast without cluttering the main channel. | + +> [!TIP] +> Using Discord Forum channels is highly recommended for active servers with multiple subscribed creators, keeping discussions organized in dedicated threads. + +--- + +## 🔗 Related Guides +- [Home](Home) — Return to wiki main page +- [Commands Reference](Commands) — Complete list of slash commands and `/set` options +- [Configuration](Configuration) — Setting up Twitch and YouTube credentials +- [Developer Guide](Development-Guide) — How to add new background monitors and alerts + +--- +[Home](Home) • [Documentation Index](Home) • [GitHub Repository](https://github.com/galnir/Master-Bot) \ No newline at end of file diff --git a/wiki/Tickets.md b/wiki/Tickets.md new file mode 100644 index 000000000..d20066cae --- /dev/null +++ b/wiki/Tickets.md @@ -0,0 +1,44 @@ +# 🎫 Support Tickets + +Master-Bot's ticketing system creates **thread-based support tickets** from a button panel, with optional manager roles and `.txt` transcript archiving. + +## ⚙️ Setup + +```bash +/set tickets set-ticket-channel #panel # where the ticket button panel lives +/set tickets set-ticket-role @Support # role that can manage/view tickets +/set tickets set-transcript-channel #transcripts # where tickets are archived +/set tickets toggle-tickets # enable the system +``` + +The panel is posted to the ticket channel with a customizable greeting message (a sensible default is provided). When disabled, the panel is removed and the buttons stop responding. + +## 🔄 Ticket Lifecycle + +```mermaid +flowchart LR + A["Member clicks 🎫 Open Ticket"] --> B["Thread created<br/>(private to member + ticket role)"] + B --> C["Ticket recorded<br/>(ticket #, timestamp)"] + C --> D["Conversation in thread"] + D --> E{"Staff close?"} + E -->|yes| F["Thread closed"] + F --> G["Transcript (.txt) posted to<br/>transcript channel"] + E -->|no| D +``` + +1. A user clicks the **Open Ticket** button on the panel (`ticketButtonListener`). +2. A **private thread** is created; the creator and any configured **ticket role** are added. +3. The thread ID, guild, creator, timestamp are persisted (`Ticket` model) so tickets can be tracked. +4. When staff close the thread, the full conversation is exported as a **`.txt` transcript** and posted to the transcript channel. + +> 💡 Transcripts make disputes easy to resolve and provide an audit trail even after the thread is archived or pruned. + +## 👥 Roles & Permissions + +- **Ticket role** (`/set tickets set-ticket-role`) — members with this role get automatic access to every ticket thread. +- The creator always has access to their own thread. +- Regular members without the role can't read other people's threads (Discord's private-thread permissions are applied at creation). + +## 🗂️ Storage + +Ticket settings (`ticketChannel`, `ticketTranscriptChannel`, `ticketRoleId`, `ticketEnabled`, `ticketMessage`) and open-ticket records persist per guild via the session layer → SQLite. If a member leaves the server, their open ticket records are cleaned up as part of the member-lifecycle cascade. \ No newline at end of file diff --git a/wiki/Welcome-and-Temp-Channels.md b/wiki/Welcome-and-Temp-Channels.md new file mode 100644 index 000000000..8b2418c9d --- /dev/null +++ b/wiki/Welcome-and-Temp-Channels.md @@ -0,0 +1,60 @@ +# 👋 Welcome & Temp Channels + +Two member-experience features that make a server feel alive: automatic **welcome messages** and on-demand **temporary voice channels**. + +## ✨ Welcome Messages + +A templated greeting posted to a channel whenever someone joins the server. + +### Setup + +```bash +/set welcome set-channel #welcome +/set welcome set-message "Welcome {user} to {server}! You are member #{position}. 🎉" +/set welcome toggle +``` + +### Template Placeholders + +| Placeholder | Replaced with | +| --- | --- | +| `{user}` | The new member's mention (`@Name`). | +| `{server}` | The server name. | +| `{position}` | The member's join position (e.g. `#128`). | + +### How It Works + +```mermaid +flowchart LR + U["Member joins server"] --> E["guildMemberAdd listener"] + E --> W{"Welcome enabled?"} + W -->|no| X["Skip"] + W -->|yes| R["Render template"] + R --> S["guildMemberAdd -> member row created<br/>(session.members)"] + S --> C["Post embed to welcome channel"] +``` + +- A welcome-enabled server also creates the member's **`GuildMember` row** at join time (see [Architecture](Architecture#️-database-tier-db_uri--schema)). +- The message is an embed built from the template; settings persist in SQLite via the session layer. + +## 🔊 Temp Voice Channels + +Convert a hub channel into a **personal voice channel factory**. + +### How It Works + +```mermaid +flowchart LR + U["User joins hub voice channel"] --> E["voiceStateUpdate listener"] + E --> T["Temp channel created<br/>(owned by user)"] + T --> M["User moved into their temp channel"] + M --> L{"User leaves / channel empty?"} + L -->|yes| D["Temp channel deleted"] + L -->|no| M +``` + +1. The server marks a voice channel as the **hub** (stored in `hubChannels`). +2. When a user joins the hub, the bot instantly creates a **temporary voice channel** for them and moves them into it. +3. When the user leaves (or the temp channel empties), the channel is **automatically deleted**, keeping the voice area tidy. + +Each temp channel is tracked (`TempChannel`: guild, owner, channel ID), so a user gets one dedicated space and staff can always see who owns which channel. Temp-channel ownership is part of the member-lifecycle data cleaned up when a member leaves. \ No newline at end of file diff --git a/wiki/_Footer.md b/wiki/_Footer.md new file mode 100644 index 000000000..8d4486c4a --- /dev/null +++ b/wiki/_Footer.md @@ -0,0 +1,9 @@ +--- + +<div align="center"> + +[🏠 **Home**](Home) • [🏗️ **Architecture**](Architecture) • [⚙️ **Configuration**](Configuration) • [🎵 **Music**](Music) • [💻 **Dashboard**](Dashboard) • [☁️ **Deployment**](Deployment) • [❓ **FAQ**](FAQ) + +**Master-Bot Documentation** • Licensed under MIT • Maintained by [galnir](https://github.com/galnir) and the Master-Bot community + +</div> diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md new file mode 100644 index 000000000..22239b54f --- /dev/null +++ b/wiki/_Sidebar.md @@ -0,0 +1,45 @@ +# 🤖 Master-Bot Wiki + +> **Master-Bot** — Unified Discord music, moderation & utility bot with embedded **Next.js 15 Web Dashboard**, dual PostgreSQL/SQLite fallbacks, and embedded **Lavalink v4 Audio**. + +--- + +## 📑 Navigation Index + +### 🚀 Getting Started +- [🏠 **Home**](Home) — Project overview & quick start +- [⚡️ **Getting Started**](Getting-Started) — Prerequisites, installation & startup +- [⚙️ **Configuration**](Configuration) — Complete `.env` reference & fallbacks + +### 🏛️ Architecture & Systems +- [🏗️ **Architecture**](Architecture) — Unified single-process runtime & storage +- [🗄️ **Database & Fallbacks**](Architecture#database-tier-db_uri--schema) — Dual PostgreSQL & SQLite +- [⚡️ **Cache & State**](Architecture#session-layer--state-management) — External Redis & `ioredis-mock` +- [📜 **Commands Reference**](Commands) — Complete catalog of slash commands & `/set` options + +### 🎵 Audio & Features +- [🎵 **Music & Audio**](Music) — Embedded Lavalink v4, filters & playlists +- [🛡️ **Moderation**](Moderation) — Ban, timeout, slowmode & audit logging +- [🎫 **Support Tickets**](Tickets) — Thread tickets & `.txt` transcripts +- [👋 **Welcome & Temp Channels**](Welcome-and-Temp-Channels) — Greetings & dynamic voice hubs +- [⏰ **Reminders & Stream Alerts**](Reminders-and-Twitch) — Scheduled reminders, YouTube & Twitch alerts + +### 🌐 Dashboard & Deployment +- [💻 **Web Dashboard**](Dashboard) — Next.js 15 App Router, tRPC v11 & 9 Studios +- [🚀 **Deployment Guide**](Deployment) — Docker, Low-Cost VPS & Self-Hosting + +### 🛠️ Developer & Legal Policies +- [🛠️ **Developer Guide**](Development-Guide) — Creating commands, listeners & features +- [🧪 **Testing Suite**](Getting-Started#testing--quality-verification) — `@helix-origin/vitest-suite` testing toolkit +- [🛡️ **Privacy Policy**](../PRIVACY.md) — Data collection, retention & deletion rights +- [📜 **Terms of Service**](../TOS.md) — Service terms, permitted use & liability +- [🔒 **Security Policy**](../SECURITY.md) — Vulnerability reporting, advisories & hardening +- [❓ **FAQ & Troubleshooting**](FAQ) — Common questions & solutions +- [🤝 **Contributing**](../CONTRIBUTING.md) — Contributor guide & commit standards + +--- + +## 🔗 Ecosystem Repositories +- 📦 **Standalone Audio**: [HELIX-Origin/Lavalink-Server](https://github.com/HELIX-Origin/Lavalink-Server) +- 🧪 **Vitest Suite**: [HELIX-Origin/vitest-suite](https://github.com/HELIX-Origin/vitest-suite) +- 🤖 **GitHub Repository**: [galnir/Master-Bot](https://github.com/galnir/Master-Bot) \ No newline at end of file