Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4670e02981 | |||
| d23e1a5d8d | |||
| 00160897b1 | |||
| b445fd1421 | |||
| 472111bd57 | |||
| f339943e3b | |||
| ba96e8c32f | |||
| e7ea2481df | |||
| 44170c8ae7 | |||
| fe4f0aec41 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# .dockerignore
|
||||||
|
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
target/
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
Lavalink.jar
|
||||||
|
application.yml
|
||||||
21
.env.example
21
.env.example
@ -1,21 +1,8 @@
|
|||||||
# Discord Bot Token
|
# .env (Docker Compose Version)
|
||||||
DISCORD_TOKEN=your_token_here
|
|
||||||
|
|
||||||
# Discord Application Client ID (for command deployment)
|
DISCORD_TOKEN=YOUR_BOT_TOKEN_HERE
|
||||||
CLIENT_ID=your_client_id_here
|
|
||||||
|
|
||||||
# Discord Guild ID (optional, for deploying commands to a specific test server)
|
# Use the Docker Compose service name for the host
|
||||||
# GUILD_ID=your_guild_id_here
|
|
||||||
|
|
||||||
# Lavalink Configuration
|
|
||||||
# Use 'lavalink' if running via docker-compose, '127.0.0.1' or 'localhost' if running Lavalink directly
|
|
||||||
LAVALINK_HOST=lavalink
|
LAVALINK_HOST=lavalink
|
||||||
LAVALINK_PORT=2333
|
LAVALINK_PORT=2333
|
||||||
LAVALINK_PASSWORD=your_password_here
|
LAVALINK_PASSWORD=youshallnotpass # Must match application.yml
|
||||||
|
|
||||||
# Logging Level (e.g., debug, info, warn, error)
|
|
||||||
LOG_LEVEL=info
|
|
||||||
|
|
||||||
# YouTube OAuth Token (Optional, for YouTube Music playback via specific plugins)
|
|
||||||
# See README for instructions on obtaining this.
|
|
||||||
YOUTUBE_OAUTH_TOKEN=your_youtube_oauth_token_here
|
|
||||||
37
.gitignore
vendored
37
.gitignore
vendored
@ -1,23 +1,22 @@
|
|||||||
# Node modules
|
# ---> Rust
|
||||||
node_modules/
|
# Generated by Cargo
|
||||||
dist/
|
# will have compiled files and executables
|
||||||
|
debug/
|
||||||
|
target/
|
||||||
|
|
||||||
# dotenv environment variables
|
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||||
.env
|
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||||
|
Cargo.lock
|
||||||
|
|
||||||
# VSCode settings
|
# These are backup files generated by rustfmt
|
||||||
.vscode/
|
**/*.rs.bk
|
||||||
|
|
||||||
# Mac system files
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
.DS_Store
|
*.pdb
|
||||||
|
|
||||||
# Lockfiles
|
# RustRover
|
||||||
pnpm-lock.yaml
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
# Logs
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
npm-debug.log*
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
logs/
|
#.idea/
|
||||||
*.log
|
|
||||||
|
|
||||||
# Data directory
|
|
||||||
data/
|
|
||||||
|
|||||||
@ -1,5 +0,0 @@
|
|||||||
dist
|
|
||||||
node_modules
|
|
||||||
coverage
|
|
||||||
build
|
|
||||||
*.d.ts
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": false,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"semi": true,
|
|
||||||
"printWidth": 100,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"bracketSpacing": true,
|
|
||||||
"arrowParens": "always",
|
|
||||||
"endOfLine": "lf"
|
|
||||||
}
|
|
||||||
18
Cargo.toml
Normal file
18
Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "discord-music-bot-lavalink-rs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serenity = { version = "0.12", features = ["builder", "cache", "client", "gateway", "model", "http", "rustls_backend", "voice", "collector"] }
|
||||||
|
lavalink-rs = { version = "0.14", features = ["serenity", "tungstenite-rustls-native-roots"] }
|
||||||
|
tokio = { version = "1.44", features = ["full"] }
|
||||||
|
dotenv = "0.15"
|
||||||
|
url = "2.5"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
anyhow = "1.0"
|
||||||
|
# Add inventory for command registration
|
||||||
|
inventory = "0.3"
|
||||||
|
# Add futures for boxed async traits
|
||||||
|
futures = "0.3"
|
||||||
75
Dockerfile
75
Dockerfile
@ -1,43 +1,42 @@
|
|||||||
# ---- Build Stage ----
|
# Dockerfile
|
||||||
FROM node:23-alpine AS builder
|
|
||||||
|
|
||||||
|
# ---- Planner Stage ----
|
||||||
|
# Use the official Rust Alpine image as a base for planning.
|
||||||
|
# We use the same base image for planner and builder for consistency.
|
||||||
|
FROM rust:1-alpine AS planner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Install cargo-chef for build caching
|
||||||
|
RUN apk add --no-cache musl-dev # Needed by chef
|
||||||
|
RUN cargo install cargo-chef --locked
|
||||||
|
COPY . .
|
||||||
|
# Compute dependencies. This output will be cached if Cargo.toml/lock haven't changed.
|
||||||
|
RUN cargo chef prepare --recipe-path recipe.json
|
||||||
|
|
||||||
# Install pnpm and necessary build tools
|
# ---- Builder Stage ----
|
||||||
RUN apk add --no-cache python3 make g++ pnpm
|
# Use the same Rust Alpine image for building.
|
||||||
|
FROM rust:1-alpine AS builder
|
||||||
# First copy all config files
|
|
||||||
COPY tsconfig.json tsconfig.deploy.json ./
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
|
||||||
|
|
||||||
# Now copy source code
|
|
||||||
COPY src/ ./src/
|
|
||||||
COPY deploy-commands.ts ./
|
|
||||||
|
|
||||||
# Install dependencies AFTER copying config files
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# Build the TypeScript code directly
|
|
||||||
RUN npx tsc -p tsconfig.json && npx tsc -p tsconfig.deploy.json
|
|
||||||
|
|
||||||
# ---- Production Stage ----
|
|
||||||
FROM node:23-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
# Install build dependencies for Alpine
|
||||||
|
RUN apk add --no-cache build-base openssl-dev pkgconfig
|
||||||
|
# Install cargo-chef again (could optimize but simpler this way)
|
||||||
|
RUN cargo install cargo-chef --locked
|
||||||
|
# Copy the dependency recipe from the planner stage
|
||||||
|
COPY --from=planner /app/recipe.json recipe.json
|
||||||
|
# Build dependencies based on the recipe. This layer is cached efficiently.
|
||||||
|
RUN cargo chef cook --release --recipe-path recipe.json
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
# Build the application binary, linking against pre-built dependencies
|
||||||
|
# Ensure the binary name matches your package name in Cargo.toml
|
||||||
|
RUN cargo build --release --bin discord-music-bot-lavalink-rs
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
# ---- Runtime Stage ----
|
||||||
|
# Use a minimal Alpine image for the final runtime environment.
|
||||||
# Copy application files
|
FROM alpine:latest
|
||||||
COPY --from=builder /app/dist ./dist
|
WORKDIR /app
|
||||||
COPY --from=builder /app/package.json ./package.json
|
# Install runtime dependencies needed by the binary (e.g., SSL certs)
|
||||||
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
RUN apk add --no-cache ca-certificates openssl
|
||||||
COPY application.yml ./application.yml
|
# Copy the compiled application binary from the builder stage
|
||||||
COPY plugins ./plugins
|
COPY --from=builder /app/target/release/discord-music-bot-lavalink-rs /app/
|
||||||
|
# Set the binary as the entrypoint
|
||||||
# Install production dependencies only
|
CMD ["./discord-music-bot-lavalink-rs"]
|
||||||
# Temporarily disable the prepare script by setting npm_config_ignore_scripts
|
|
||||||
RUN apk add --no-cache pnpm && \
|
|
||||||
npm_config_ignore_scripts=true pnpm install --prod --frozen-lockfile
|
|
||||||
|
|
||||||
# Run the compiled JavaScript application
|
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
319
README.md
319
README.md
@ -1,167 +1,224 @@
|
|||||||
# Discord Music Bot (TypeScript)
|
# Rust LavaLink Discord Music Bot Prototype
|
||||||
|
|
||||||
Discord music bot template written in TypeScript using `discord.js` and `shoukaku`, with Lavalink support.
|
A prototype Discord music bot built with Rust, using the [Serenity](https://github.com/serenity-rs/serenity) library for Discord interactions and the [lavalink-rs](https://github.com/adamsoutar/lavalink-rs) crate to interface with a [LavaLink](https://github.com/lavalink-devs/Lavalink) audio server. This bot utilizes slash commands for user interaction and features a modular command structure using the `inventory` crate. It can be run natively or using Docker Compose for easier setup and deployment.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Slash commands (e.g., `/ping`, `/join`, `/play`, `/leave`)
|
* Connects to Discord via the Gateway.
|
||||||
- `shoukaku` integration for robust Lavalink audio playback
|
* Connects to a LavaLink node for audio processing.
|
||||||
- Modular command and event handlers written in TypeScript
|
* Slash command support for core music functions:
|
||||||
- Basic Docker support (`Dockerfile`, `docker-compose.yml`)
|
* `/join`: Connect the bot to your voice channel.
|
||||||
- Comprehensive test suite with Jest
|
* `/play`: Play audio from a URL or search query (YouTube default). Handles single tracks and playlists.
|
||||||
|
* `/skip`: Skip the currently playing track.
|
||||||
|
* `/leave`: Disconnect the bot from the voice channel.
|
||||||
|
* Basic queueing (handled internally by LavaLink).
|
||||||
|
* Modular command system using `inventory` for easy addition of new commands.
|
||||||
|
* Basic auto-leaving functionality when the bot is alone in a voice channel (with a short delay).
|
||||||
|
* Asynchronous architecture using Tokio.
|
||||||
|
* Logging using `tracing`.
|
||||||
|
* Containerized setup using Docker and Docker Compose.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js (>=16 recommended, check `package.json` for specific engine requirements)
|
### For Native Execution
|
||||||
- pnpm (recommended) or npm
|
|
||||||
- TypeScript (`typescript` package, usually installed as a dev dependency)
|
1. **Rust Toolchain:** Install Rust using [rustup](https://rustup.rs/). (`rustup update` recommended).
|
||||||
- A Discord application with bot token and client ID
|
2. **LavaLink Server:**
|
||||||
- A running Lavalink server
|
* Download the latest `Lavalink.jar` from the [LavaLink Releases](https://github.com/lavalink-devs/Lavalink/releases).
|
||||||
|
* A **Java Runtime Environment (JRE)** (Version 11 or higher, 17 recommended) to run the `.jar` file.
|
||||||
|
* Configure the `application.yml` file for LavaLink.
|
||||||
|
3. **Discord Bot Application:** (See details below)
|
||||||
|
|
||||||
|
### For Docker Execution
|
||||||
|
|
||||||
|
1. **Docker & Docker Compose:** Install Docker Desktop (which includes Compose) or Docker Engine and the Docker Compose plugin. See the [official Docker installation guide](https://docs.docker.com/engine/install/).
|
||||||
|
2. **LavaLink Server Files:** You still need to download `Lavalink.jar` and create/configure `application.yml` locally, as these will be mounted into the LavaLink container.
|
||||||
|
3. **Discord Bot Application:** (See details below)
|
||||||
|
|
||||||
|
### Discord Bot Application Details (Common)
|
||||||
|
|
||||||
|
* Create a bot application on the [Discord Developer Portal](https://discord.com/developers/applications).
|
||||||
|
* Retrieve the **Bot Token**.
|
||||||
|
* Enable the **Privileged Gateway Intents**:
|
||||||
|
* `PRESENCE INTENT` (Optional)
|
||||||
|
* `SERVER MEMBERS INTENT` (Optional, not strictly required for prototype)
|
||||||
|
* `MESSAGE CONTENT INTENT` (Optional, not strictly required for prototype)
|
||||||
|
* **`GUILD_VOICE_STATES`** (**Required** for voice functionality)
|
||||||
|
* Invite the bot to your Discord server with necessary permissions: `Connect`, `Speak`, `Send Messages`/`Send Messages in Threads`, `Use Application Commands`.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. **Clone the repository:**
|
1. **Clone the Repository:**
|
||||||
```sh
|
|
||||||
git clone <repository_url>
|
```bash
|
||||||
cd discord-music-bot
|
git clone <repository-url>
|
||||||
|
cd discord-music-bot-lavalink-rs
|
||||||
```
|
```
|
||||||
2. **Install dependencies:**
|
|
||||||
```sh
|
2. **Prepare LavaLink Files:**
|
||||||
pnpm install
|
* Download the latest `Lavalink.jar` from [LavaLink Releases](https://github.com/lavalink-devs/Lavalink/releases) and place it in the project root directory.
|
||||||
|
* Create or copy an `application.yml` file into the project root directory. Configure at least the `lavalink.server.password`. Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# application.yml
|
||||||
|
server:
|
||||||
|
port: 2333
|
||||||
|
address: 0.0.0.0
|
||||||
|
lavalink:
|
||||||
|
server:
|
||||||
|
password: "youshallnotpass" # CHANGE THIS
|
||||||
|
sources: # Enable desired sources
|
||||||
|
youtube: true
|
||||||
|
soundcloud: true
|
||||||
|
# Add other configurations as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure Environment Variables:**
|
||||||
|
Create a `.env` file in the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
```
|
```
|
||||||
3. **Configure Environment:**
|
|
||||||
Copy `.env.example` to `.env` and fill in your credentials:
|
|
||||||
```dotenv
|
|
||||||
# Discord Bot Token (Required)
|
|
||||||
DISCORD_TOKEN=your_discord_bot_token
|
|
||||||
|
|
||||||
# Discord Application Client ID (Required for command deployment)
|
Edit `.env` with your credentials:
|
||||||
CLIENT_ID=your_discord_application_id
|
|
||||||
|
|
||||||
# Discord Guild ID (Optional, for deploying commands to a specific test server)
|
```env
|
||||||
# GUILD_ID=your_guild_id_here
|
# .env
|
||||||
|
DISCORD_TOKEN=YOUR_BOT_TOKEN_HERE
|
||||||
|
|
||||||
# Lavalink Configuration (Required)
|
# --- IMPORTANT: Choose ONE Lavalink Host setting ---
|
||||||
LAVALINK_HOST=lavalink # Or 127.0.0.1 if running locally without Docker Compose
|
|
||||||
|
# == For Docker Compose Execution ==
|
||||||
|
# Use the service name defined in docker-compose.yml
|
||||||
|
LAVALINK_HOST=lavalink
|
||||||
|
|
||||||
|
# == For Native Execution ==
|
||||||
|
# Use the actual IP or hostname of your LavaLink server
|
||||||
|
# LAVALINK_HOST=127.0.0.1 # Or e.g., other-machine-ip
|
||||||
|
|
||||||
|
# --- Common Settings ---
|
||||||
LAVALINK_PORT=2333
|
LAVALINK_PORT=2333
|
||||||
LAVALINK_PASSWORD=your_lavalink_password
|
LAVALINK_PASSWORD=youshallnotpass # Must match password in application.yml
|
||||||
# LAVALINK_SECURE=false # Set to true if Lavalink uses SSL/WSS
|
|
||||||
|
|
||||||
# Logging Level (Optional, defaults typically to 'info')
|
|
||||||
# LOG_LEVEL=info
|
|
||||||
|
|
||||||
# YouTube OAuth Token (Optional, needed for YouTube Music via specific plugins)
|
|
||||||
# See note below on how to obtain this.
|
|
||||||
YOUTUBE_OAUTH_TOKEN=your_youtube_oauth_token_here
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note on YouTube OAuth Token:**
|
**Note:** Ensure the `LAVALINK_PASSWORD` here matches the one in `application.yml`.
|
||||||
The `YOUTUBE_OAUTH_TOKEN` is required by some Lavalink plugins (like the `youtube-plugin` potentially used here) to access YouTube Music tracks directly. Obtaining this involves:
|
|
||||||
1. Go to the [Google Cloud Console](https://console.cloud.google.com/).
|
|
||||||
2. Create a new project or select an existing one.
|
|
||||||
3. Navigate to "APIs & Services" -> "Credentials".
|
|
||||||
4. Click "Create Credentials" -> "OAuth client ID".
|
|
||||||
5. Select Application type: **"TVs and Limited Input devices"**.
|
|
||||||
6. Give it a name (e.g., "Lavalink YouTube Music Access").
|
|
||||||
7. Click "Create". You'll get a Client ID and Client Secret (you likely won't need the secret directly for the token flow).
|
|
||||||
8. Follow the on-screen instructions or Google's documentation for the "OAuth 2.0 for TV and Limited Input devices" flow. This usually involves visiting a specific URL with your client ID, getting a user code, authorizing the application on a separate device/browser logged into your Google account, and then exchanging the device code for a **refresh token**.
|
|
||||||
9. Paste the obtained **refresh token** as the value for `YOUTUBE_OAUTH_TOKEN` in your `.env` file.
|
|
||||||
|
|
||||||
4. **Build TypeScript (if needed):**
|
## Running the Bot
|
||||||
Many setups use `ts-node` for development, but for production, you might need to compile:
|
|
||||||
```sh
|
You can run the bot natively or using Docker Compose.
|
||||||
pnpm build # Check package.json for the exact build script
|
|
||||||
|
### Option 1: Running with Docker Compose (Recommended)
|
||||||
|
|
||||||
|
This method runs both the bot and the LavaLink server in isolated containers.
|
||||||
|
|
||||||
|
1. **Ensure Prerequisites:** Docker, Docker Compose, `Lavalink.jar`, `application.yml`, configured `.env` (with `LAVALINK_HOST=lavalink`).
|
||||||
|
2. **Build and Start:** In the project root, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Register Slash Commands:**
|
* `--build`: Builds the bot image. Needed the first time or after code changes.
|
||||||
Run the deployment script (ensure `CLIENT_ID` and `DISCORD_TOKEN` are set in `.env`).
|
* `-d`: Runs containers in the background.
|
||||||
```sh
|
3. **Check Logs (Optional):**
|
||||||
pnpm deploy # Check package.json for the exact deploy script (might be node/ts-node deploy-commands.ts)
|
|
||||||
|
```bash
|
||||||
|
docker compose logs -f app # View bot logs
|
||||||
|
docker compose logs -f lavalink # View LavaLink logs
|
||||||
```
|
```
|
||||||
|
|
||||||
6. **Start the Bot:**
|
Press `Ctrl+C` to stop following.
|
||||||
```sh
|
4. **Stopping:**
|
||||||
pnpm start # Check package.json for the exact start script (might run compiled JS or use ts-node)
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
### Option 2: Running Natively
|
||||||
|
|
||||||
The project includes a comprehensive test suite using Jest. The tests cover commands, events, and utilities.
|
This method requires you to run LavaLink separately.
|
||||||
|
|
||||||
### Running Tests
|
1. **Start LavaLink:**
|
||||||
|
Navigate to the directory containing `Lavalink.jar` and `application.yml`. Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests with coverage report
|
java -jar Lavalink.jar
|
||||||
pnpm test
|
|
||||||
|
|
||||||
# Run tests in watch mode during development
|
|
||||||
pnpm test:watch
|
|
||||||
|
|
||||||
# Run tests in CI environment
|
|
||||||
pnpm test:ci
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
tests/
|
|
||||||
├── commands/ # Tests for bot commands
|
|
||||||
│ ├── join.test.ts
|
|
||||||
│ ├── leave.test.ts
|
|
||||||
│ ├── ping.test.ts
|
|
||||||
│ └── play.test.ts
|
|
||||||
├── events/ # Tests for event handlers
|
|
||||||
│ ├── interactionCreate.test.ts
|
|
||||||
│ ├── ready.test.ts
|
|
||||||
│ └── voiceStateUpdate.test.ts
|
|
||||||
└── utils/ # Test utilities and mocks
|
|
||||||
├── setup.ts # Jest setup and global mocks
|
|
||||||
├── testUtils.ts # Common test utilities
|
|
||||||
└── types.ts # TypeScript types for tests
|
|
||||||
```
|
|
||||||
|
|
||||||
### Coverage Requirements
|
|
||||||
|
|
||||||
The project maintains high test coverage requirements:
|
|
||||||
|
|
||||||
- Branches: 80%
|
|
||||||
- Functions: 80%
|
|
||||||
- Lines: 80%
|
|
||||||
- Statements: 80%
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
A `Dockerfile` and `docker-compose.yml` are provided for containerized deployment.
|
|
||||||
|
|
||||||
- Ensure your `.env` file is configured correctly.
|
|
||||||
- Build and run with Docker Compose:
|
|
||||||
```sh
|
|
||||||
docker-compose up --build -d # Use -d to run in detached mode
|
|
||||||
```
|
```
|
||||||
- The `docker-compose.yml` includes both the bot service and a Lavalink service.
|
|
||||||
|
Keep this terminal open.
|
||||||
|
2. **Build the Bot (if needed):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure `.env`:** Ensure `LAVALINK_HOST` in your `.env` file points to the correct IP/hostname where LavaLink is running (e.g., `127.0.0.1` if running on the same machine).
|
||||||
|
4. **Run the Bot:**
|
||||||
|
In a **new terminal**, navigate to the project root and run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Using cargo
|
||||||
|
cargo run
|
||||||
|
# Or using the compiled release binary
|
||||||
|
# target/release/discord-music-bot-lavalink-rs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Commands
|
||||||
|
|
||||||
|
Use these commands in your Discord server where the bot is present:
|
||||||
|
|
||||||
|
* `/join` : Makes the bot join the voice channel you are currently in.
|
||||||
|
* `/play query:<url or search term>` : Plays a song from a URL (direct link or playlist) or searches YouTube for the term and plays the first result. Queues subsequent tracks/playlists.
|
||||||
|
* `/skip` : Skips the song currently playing.
|
||||||
|
* `/leave` : Disconnects the bot from the voice channel.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
.
|
discord-music-bot-lavalink-rs/
|
||||||
├── src/ # Source code directory
|
├── .env # Local environment variables (ignored by git)
|
||||||
│ ├── commands/ # Slash command modules (.ts)
|
├── .env.example # Example environment file
|
||||||
│ ├── events/ # Discord.js and Shoukaku event handlers (.ts)
|
├── .dockerignore # Files ignored by Docker context
|
||||||
│ ├── structures/ # Custom structures or base classes (e.g., Shoukaku event handlers)
|
├── Dockerfile # Instructions to build the bot's Docker image
|
||||||
│ └── utils/ # Utility functions (e.g., logger.ts)
|
├── docker-compose.yml # Defines bot and LavaLink services for Docker Compose
|
||||||
├── tests/ # Test files (see Testing section)
|
├── Lavalink.jar # LavaLink server executable (add locally)
|
||||||
├── plugins/ # Lavalink plugins (e.g., youtube-plugin-*.jar)
|
├── application.yml # LavaLink configuration (add locally)
|
||||||
├── .env.example # Example environment variables
|
├── Cargo.toml # Rust project manifest, dependencies
|
||||||
├── application.yml # Lavalink server configuration
|
└── src/
|
||||||
├── deploy-commands.ts # Script to register slash commands
|
├── main.rs # Entry point, client setup, task spawning
|
||||||
├── docker-compose.yml # Docker Compose configuration
|
├── state.rs # Definition of the shared BotState struct
|
||||||
├── Dockerfile # Dockerfile for building the bot image
|
├── handler.rs # Serenity event handler (ready, interaction_create, voice_state_update)
|
||||||
├── jest.config.ts # Jest test configuration
|
├── lavalink_handler.rs # lavalink-rs event handler (track start/end, etc.)
|
||||||
├── package.json # Node.js project manifest
|
├── utils.rs # Utility functions (e.g., get_voice_state)
|
||||||
├── tsconfig.json # TypeScript compiler options
|
└── commands/ # Directory for slash command implementations
|
||||||
└── update-plugin.sh # Script to update Lavalink plugins
|
├── mod.rs # Command registration setup (inventory, traits)
|
||||||
|
├── join.rs # /join command implementation & registration
|
||||||
|
├── leave.rs # /leave command implementation & registration
|
||||||
|
├── play.rs # /play command implementation & registration
|
||||||
|
└── skip.rs # /skip command implementation & registration
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## Key Dependencies
|
||||||
|
|
||||||
This project is licensed under the **GNU General Public License v3.0**. See the [LICENSE](LICENSE) file for details.
|
* [Serenity](https://github.com/serenity-rs/serenity): Discord API library for Rust.
|
||||||
|
* [lavalink-rs](https://github.com/adamsoutar/lavalink-rs): Client implementation for the LavaLink protocol.
|
||||||
|
* [Tokio](https://tokio.rs/): Asynchronous runtime.
|
||||||
|
* [Inventory](https://github.com/dtolnay/inventory): Type-driven dependency injection / global struct collection (used for command registration).
|
||||||
|
* [Anyhow](https://github.com/dtolnay/anyhow): Flexible error handling.
|
||||||
|
* [Tracing](https://github.com/tokio-rs/tracing): Application-level tracing and logging framework.
|
||||||
|
* [Url](https://crates.io/crates/url): URL parsing.
|
||||||
|
* [Dotenv](https://crates.io/crates/dotenv): Loading environment variables from `.env` files.
|
||||||
|
* [Docker](https://www.docker.com/): Containerization platform.
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
This is a prototype, and many features could be added or improved:
|
||||||
|
|
||||||
|
* [] **More Commands:** `/queue`, `/pause`, `/resume`, `/nowplaying`, `/volume`, `/remove`, `/loop`, `/shuffle`, etc.
|
||||||
|
* [] **Robust Error Handling:** More specific error types and user-friendly feedback.
|
||||||
|
* [] **Queue Management:** Displaying the queue, allowing users to remove specific tracks.
|
||||||
|
* [] **Permissions:** Restricting commands based on user roles or voice channel status.
|
||||||
|
* [] **Configuration:** Per-guild settings (e.g., announcement channels, DJ roles).
|
||||||
|
* [] **Player Persistence:** Saving/loading player state across bot restarts (more complex).
|
||||||
|
* [] **Multi-Node Support:** Utilizing multiple LavaLink nodes for scalability.
|
||||||
|
* [] **Tests:** Adding unit and integration tests.
|
||||||
126
application.yml
126
application.yml
@ -1,116 +1,32 @@
|
|||||||
|
# application.yml (Example)
|
||||||
server: # REST and WS server
|
server: # REST and WS server
|
||||||
port: 2333
|
port: 2333
|
||||||
address: 0.0.0.0
|
address: 0.0.0.0 # Listen on all interfaces within the container
|
||||||
http2:
|
|
||||||
enabled: false # Whether to enable HTTP/2 support
|
|
||||||
# Root level plugin configuration block
|
|
||||||
plugins:
|
|
||||||
youtube:
|
|
||||||
enabled: true # Whether this source can be used.
|
|
||||||
allowSearch: true # Whether "ytsearch:" and "ytmsearch:" can be used.
|
|
||||||
allowDirectVideoIds: true # Whether just video IDs can match. If false, only complete URLs will be loaded.
|
|
||||||
allowDirectPlaylistIds: true # Whether just playlist IDs can match. If false, only complete URLs will be loaded.
|
|
||||||
# The clients to use for track loading. See below for a list of valid clients.
|
|
||||||
# Clients are queried in the order they are given (so the first client is queried first and so on...)
|
|
||||||
clients:
|
|
||||||
- WEB
|
|
||||||
- WEBEMBEDDED
|
|
||||||
- MUSIC
|
|
||||||
oauth:
|
|
||||||
enabled: true
|
|
||||||
# If you obtain a refresh token after the initial OAuth flow, you can add it here
|
|
||||||
# refreshToken: "paste your refresh token here if applicable"
|
|
||||||
# Leave skipInitialization commented for first-time setup
|
|
||||||
# skipInitialization: true
|
|
||||||
lavalink:
|
lavalink:
|
||||||
plugins:
|
|
||||||
# - dependency: "com.github.example:example-plugin:1.0.0" # required, the coordinates of your plugin
|
|
||||||
# repository: "https://maven.example.com/releases" # optional, defaults to the Lavalink releases repository by default
|
|
||||||
# snapshot: false # optional, defaults to false, used to tell Lavalink to use the snapshot repository instead of the release repository
|
|
||||||
pluginsDir: "/plugins" # Set directory for manually loaded plugins
|
|
||||||
# defaultPluginRepository: "https://maven.lavalink.dev/releases" # optional, defaults to the Lavalink release repository
|
|
||||||
# defaultPluginSnapshotRepository: "https://maven.lavalink.dev/snapshots" # optional, defaults to the Lavalink snapshot repository
|
|
||||||
server:
|
server:
|
||||||
password: "${LAVALINK_PASSWORD}" # Use environment variable
|
password: "youshallnotpass" # CHANGE THIS to a strong password
|
||||||
sources:
|
sources:
|
||||||
# The default Youtube source is now deprecated and won't receive further updates. Please use https://github.com/lavalink-devs/youtube-source#plugin instead.
|
youtube: true
|
||||||
youtube: false
|
bandcamp: true
|
||||||
bandcamp: false
|
soundcloud: true
|
||||||
soundcloud: false
|
twitch: true
|
||||||
twitch: false
|
vimeo: true
|
||||||
vimeo: false
|
http: true
|
||||||
nico: false
|
|
||||||
http: false # warning: keeping HTTP enabled without a proxy configured could expose your server's IP address.
|
|
||||||
local: false
|
local: false
|
||||||
filters: # All filters are enabled by default
|
# bufferDurationMs: 400 # How many milliseconds of audio to buffer? Lower values are less safe.
|
||||||
volume: true
|
# youtubePlaylistLoadLimit: 6 # Number of pages at 100 tracks each
|
||||||
equalizer: true
|
# playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds
|
||||||
karaoke: true
|
# youtubeSearchEnabled: true
|
||||||
timescale: true
|
# soundcloudSearchEnabled: true
|
||||||
tremolo: true
|
# bandcampSearchEnabled: true # Not implemented by LavaLink currently
|
||||||
vibrato: true
|
# Default values are generally fine
|
||||||
distortion: true
|
# You can find more options here: https://github.com/lavalink-devs/Lavalink/blob/master/IMPLEMENTATION.md#configuration
|
||||||
rotation: true
|
|
||||||
channelMix: true
|
|
||||||
lowPass: true
|
|
||||||
nonAllocatingFrameBuffer: false # Setting to true reduces the number of allocations made by each player at the expense of frame rebuilding (e.g. non-instantaneous volume changes)
|
|
||||||
bufferDurationMs: 400 # The duration of the NAS buffer. Higher values fare better against longer GC pauses. Duration <= 0 to disable JDA-NAS. Minimum of 40ms, lower values may introduce pauses.
|
|
||||||
frameBufferDurationMs: 5000 # How many milliseconds of audio to keep buffered
|
|
||||||
opusEncodingQuality: 10 # Opus encoder quality. Valid values range from 0 to 10, where 10 is best quality but is the most expensive on the CPU.
|
|
||||||
resamplingQuality: LOW # Quality of resampling operations. Valid values are LOW, MEDIUM and HIGH, where HIGH uses the most CPU.
|
|
||||||
trackStuckThresholdMs: 10000 # The threshold for how long a track can be stuck. A track is stuck if does not return any audio data.
|
|
||||||
useSeekGhosting: true # Seek ghosting is the effect where whilst a seek is in progress, the audio buffer is read from until empty, or until seek is ready.
|
|
||||||
youtubePlaylistLoadLimit: 6 # Number of pages at 100 each
|
|
||||||
playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds
|
|
||||||
youtubeSearchEnabled: true
|
|
||||||
soundcloudSearchEnabled: true
|
|
||||||
gc-warnings: true
|
|
||||||
#ratelimit:
|
|
||||||
#ipBlocks: ["1.0.0.0/8", "..."] # list of ip blocks
|
|
||||||
#excludedIps: ["...", "..."] # ips which should be explicit excluded from usage by lavalink
|
|
||||||
#strategy: "RotateOnBan" # RotateOnBan | LoadBalance | NanoSwitch | RotatingNanoSwitch
|
|
||||||
#searchTriggersFail: true # Whether a search 429 should trigger marking the ip as failing
|
|
||||||
#retryLimit: -1 # -1 = use default lavaplayer value | 0 = infinity | >0 = retry will happen this numbers times
|
|
||||||
#youtubeConfig: # Required for avoiding all age restrictions by YouTube, some restricted videos still can be played without.
|
|
||||||
#email: "" # Email of Google account
|
|
||||||
#password: "" # Password of Google account
|
|
||||||
#httpConfig: # Useful for blocking bad-actors from ip-grabbing your music node and attacking it, this way only the http proxy will be attacked
|
|
||||||
#proxyHost: "localhost" # Hostname of the proxy, (ip or domain)
|
|
||||||
#proxyPort: 3128 # Proxy port, 3128 is the default for squidProxy
|
|
||||||
#proxyUser: "" # Optional user for basic authentication fields, leave blank if you don't use basic auth
|
|
||||||
#proxyPassword: "" # Password for basic authentication
|
|
||||||
|
|
||||||
metrics:
|
|
||||||
prometheus:
|
|
||||||
enabled: false
|
|
||||||
endpoint: /metrics
|
|
||||||
|
|
||||||
sentry:
|
|
||||||
dsn: ""
|
|
||||||
environment: ""
|
|
||||||
# tags:
|
|
||||||
# some_key: some_value
|
|
||||||
# another_key: another_value
|
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
file:
|
file:
|
||||||
path: ./logs/
|
max-history: 30
|
||||||
|
max-size: 1GB
|
||||||
|
path: ./logs/
|
||||||
|
|
||||||
level:
|
level:
|
||||||
root: INFO
|
root: INFO
|
||||||
lavalink: INFO
|
lavalink: INFO
|
||||||
dev.lavalink.youtube: INFO # General YouTube plugin logging
|
|
||||||
dev.lavalink.youtube.http.YoutubeOauth2Handler: INFO # Specific OAuth flow logging
|
|
||||||
|
|
||||||
request:
|
|
||||||
enabled: true
|
|
||||||
includeClientInfo: true
|
|
||||||
includeHeaders: false
|
|
||||||
includeQueryString: true
|
|
||||||
includePayload: true
|
|
||||||
maxPayloadLength: 10000
|
|
||||||
|
|
||||||
logback:
|
|
||||||
rollingpolicy:
|
|
||||||
max-file-size: 1GB
|
|
||||||
max-history: 30
|
|
||||||
43
compose.yml
Normal file
43
compose.yml
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# docker-compose.yml
|
||||||
|
services:
|
||||||
|
# The Rust Discord Bot Service
|
||||||
|
app:
|
||||||
|
# Build the image from the Dockerfile in the current directory (.)
|
||||||
|
build: .
|
||||||
|
container_name: discord-bot-app
|
||||||
|
restart: unless-stopped
|
||||||
|
# Pass environment variables from the .env file
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
# Depend on the lavalink service to ensure it starts first (best practice)
|
||||||
|
depends_on:
|
||||||
|
- lavalink
|
||||||
|
|
||||||
|
# The LavaLink Service
|
||||||
|
lavalink:
|
||||||
|
# Use a Java Runtime Environment image (Alpine base for smaller size)
|
||||||
|
image: eclipse-temurin:17-jre-alpine
|
||||||
|
container_name: lavalink-server
|
||||||
|
restart: unless-stopped
|
||||||
|
# Set the working directory inside the container
|
||||||
|
working_dir: /opt/lavalink
|
||||||
|
# Mount the local Lavalink.jar and application.yml into the container
|
||||||
|
volumes:
|
||||||
|
- ./Lavalink.jar:/opt/lavalink/Lavalink.jar:ro,Z # Mount Jar read-only
|
||||||
|
- ./application.yml:/opt/lavalink/application.yml:ro,Z # Mount config read-only
|
||||||
|
- ./logs:/opt/lavalink/logs:Z # Mount logs directory (optional)
|
||||||
|
# Expose the LavaLink port to the host machine (optional, but useful for debugging)
|
||||||
|
# and makes it reachable by the 'app' service via Docker network.
|
||||||
|
ports:
|
||||||
|
- "2333:2333"
|
||||||
|
# Command to run LavaLink inside the container
|
||||||
|
command: ["java", "-jar", "Lavalink.jar"]
|
||||||
|
|
||||||
|
# Define networks (optional, Docker Compose creates a default one)
|
||||||
|
# networks:
|
||||||
|
# default:
|
||||||
|
# driver: bridge
|
||||||
|
|
||||||
|
# Define volumes (optional, if you need persistent data beyond logs)
|
||||||
|
# volumes:
|
||||||
|
# lavalink_logs:
|
||||||
@ -1,108 +0,0 @@
|
|||||||
import { REST, Routes, APIApplicationCommand } from "discord.js";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import logger from "./src/utils/logger.js"; // Added .js extension for ES modules
|
|
||||||
import dotenv from "dotenv";
|
|
||||||
|
|
||||||
// --- Setup ---
|
|
||||||
dotenv.config(); // Load .env variables
|
|
||||||
|
|
||||||
// Log presence of required env vars (optional, but helpful for debugging)
|
|
||||||
// logger.info(`CLIENT_ID: ${process.env.CLIENT_ID ? 'Present' : 'MISSING!'}`);
|
|
||||||
// logger.info(`DISCORD_TOKEN: ${process.env.DISCORD_TOKEN ? 'Present' : 'MISSING!'}`);
|
|
||||||
|
|
||||||
// --- Configuration ---
|
|
||||||
const clientId = process.env.CLIENT_ID;
|
|
||||||
const token = process.env.DISCORD_TOKEN;
|
|
||||||
|
|
||||||
if (!clientId || !token) {
|
|
||||||
logger.error("Missing CLIENT_ID or DISCORD_TOKEN in .env file for command deployment!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const commands: Omit<APIApplicationCommand, "id" | "application_id" | "version">[] = []; // Type the commands array more accurately
|
|
||||||
// Grab all the command files from the commands directory
|
|
||||||
const commandsPath = path.join(__dirname, "src", "commands");
|
|
||||||
// Read .ts files now
|
|
||||||
const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith(".ts")); // Add string type
|
|
||||||
|
|
||||||
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
|
|
||||||
logger.info(`Started loading ${commandFiles.length} application (/) commands for deployment.`);
|
|
||||||
|
|
||||||
const loadCommandsForDeployment = async () => {
|
|
||||||
for (const file of commandFiles) {
|
|
||||||
const filePath = path.join(commandsPath, file);
|
|
||||||
try {
|
|
||||||
// Use dynamic import with file:// protocol for ES modules
|
|
||||||
const fileUrl = new URL(`file://${filePath}`);
|
|
||||||
const commandModule = await import(fileUrl.href);
|
|
||||||
// Assuming commands export default or have a 'default' property
|
|
||||||
const command = commandModule.default || commandModule;
|
|
||||||
|
|
||||||
if (
|
|
||||||
command &&
|
|
||||||
typeof command === "object" &&
|
|
||||||
"data" in command &&
|
|
||||||
typeof command.data.toJSON === "function"
|
|
||||||
) {
|
|
||||||
// We push the JSON representation which matches the API structure
|
|
||||||
commands.push(command.data.toJSON());
|
|
||||||
logger.info(`Loaded command for deployment: ${command.data.name}`);
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
`[WARNING] The command at ${filePath} is missing a required "data" property with a "toJSON" method.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Type error as unknown
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(`Error loading command at ${filePath} for deployment: ${errorMessage}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Construct and prepare an instance of the REST module
|
|
||||||
const rest = new REST({ version: "10" }).setToken(token);
|
|
||||||
|
|
||||||
// Define the deployment function
|
|
||||||
const deployCommands = async () => {
|
|
||||||
try {
|
|
||||||
await loadCommandsForDeployment(); // Wait for commands to be loaded
|
|
||||||
|
|
||||||
if (commands.length === 0) {
|
|
||||||
logger.warn("No commands loaded for deployment. Exiting.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Started refreshing ${commands.length} application (/) commands.`);
|
|
||||||
|
|
||||||
// The put method is used to fully refresh all commands
|
|
||||||
const guildId = process.env.GUILD_ID;
|
|
||||||
let data: any; // Type appropriately if possible, depends on discord.js version
|
|
||||||
|
|
||||||
if (guildId) {
|
|
||||||
// Deploying to a specific guild (faster for testing)
|
|
||||||
logger.info(`Deploying commands to guild: ${guildId}`);
|
|
||||||
data = await rest.put(Routes.applicationGuildCommands(clientId, guildId), { body: commands });
|
|
||||||
logger.info(
|
|
||||||
`Successfully reloaded ${data.length} application (/) commands in guild ${guildId}.`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Deploying globally (can take up to an hour)
|
|
||||||
logger.info("Deploying commands globally...");
|
|
||||||
data = await rest.put(Routes.applicationCommands(clientId), { body: commands });
|
|
||||||
logger.info(`Successfully reloaded ${data.length} global application (/) commands.`);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Type error as unknown
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(`Failed during command deployment: ${errorMessage}`, error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Execute the deployment
|
|
||||||
deployCommands();
|
|
||||||
|
|
||||||
// Note: The old wipe logic is removed as PUT overwrites existing commands.
|
|
||||||
// If you specifically need to wipe commands first for some reason,
|
|
||||||
// you can add separate PUT requests with an empty body before deploying.
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
services:
|
|
||||||
lavalink:
|
|
||||||
image: fredboat/lavalink:latest
|
|
||||||
container_name: lavalink
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- bot-network
|
|
||||||
ports:
|
|
||||||
- "2333:2333"
|
|
||||||
environment:
|
|
||||||
- LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD}
|
|
||||||
# Removed LAVALINK_PLUGIN_URLS environment variable
|
|
||||||
volumes:
|
|
||||||
- ./application.yml:/opt/Lavalink/application.yml:ro,Z
|
|
||||||
# Mount local plugins directory into the container with SELinux label
|
|
||||||
- ./plugins:/plugins:ro,Z
|
|
||||||
# Add healthcheck to verify Lavalink is ready
|
|
||||||
healthcheck:
|
|
||||||
# Use CMD-SHELL to allow environment variable expansion for the password
|
|
||||||
test: ["CMD-SHELL", "curl -H \"Authorization: $$LAVALINK_SERVER_PASSWORD\" -f http://localhost:2333/version || exit 1"]
|
|
||||||
interval: 10s # Increased interval slightly
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 15s # Give Lavalink time to start up initially
|
|
||||||
# Removed command override, will use default image entrypoint
|
|
||||||
|
|
||||||
bot:
|
|
||||||
build: .
|
|
||||||
container_name: discord-music-bot
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- bot-network
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
LAVALINK_HOST: lavalink
|
|
||||||
LAVALINK_PORT: 2333
|
|
||||||
LAVALINK_PASSWORD: ${LAVALINK_PASSWORD}
|
|
||||||
# Update depends_on to wait for healthcheck
|
|
||||||
depends_on:
|
|
||||||
lavalink:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
networks:
|
|
||||||
bot-network:
|
|
||||||
driver: bridge
|
|
||||||
36
package.json
36
package.json
@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "discord-music-bot",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc -p tsconfig.json",
|
|
||||||
"build:deploy": "tsc -p tsconfig.deploy.json",
|
|
||||||
"build:all": "npm run build && npm run build:deploy",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"format": "prettier --write src/**/*.ts deploy-commands.ts",
|
|
||||||
"prepare": "npm run build:all"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"discord.js": "^14.18.0",
|
|
||||||
"dotenv": "^16.5.0",
|
|
||||||
"shoukaku": "^4.1.1",
|
|
||||||
"winston": "^3.17.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
|
||||||
"@types/js-yaml": "^4.0.9",
|
|
||||||
"@types/node": "^22.14.1",
|
|
||||||
"js-yaml": "^4.1.0",
|
|
||||||
"npm": "^11.3.0",
|
|
||||||
"prettier": "^3.5.3",
|
|
||||||
"ts-node-dev": "^2.0.0",
|
|
||||||
"typescript": "^5.8.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
3
rustfmt.toml
Normal file
3
rustfmt.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# rustfmt.toml
|
||||||
|
hard_tabs = false
|
||||||
|
tab_spaces = 2
|
||||||
@ -1,60 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Get all TypeScript files in a directory recursively
|
|
||||||
function getTypeScriptFiles(dir) {
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
function traverse(currentDir) {
|
|
||||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(currentDir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
traverse(fullPath);
|
|
||||||
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
traverse(dir);
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix imports in a file
|
|
||||||
function fixImportsInFile(filePath) {
|
|
||||||
console.log(`Processing ${filePath}`);
|
|
||||||
let content = fs.readFileSync(filePath, 'utf8');
|
|
||||||
|
|
||||||
// Regular expression to match relative imports without file extensions
|
|
||||||
const importRegex = /(import\s+(?:[^'"]*\s+from\s+)?['"])(\.\.[^'"]*?)(['"])/g;
|
|
||||||
|
|
||||||
// Add .js extension to relative imports
|
|
||||||
content = content.replace(importRegex, (match, start, importPath, end) => {
|
|
||||||
// Don't add extension if it already has one or ends with a directory
|
|
||||||
if (importPath.endsWith('.js') || importPath.endsWith('/')) {
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
return `${start}${importPath}.js${end}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(filePath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main function
|
|
||||||
function main() {
|
|
||||||
const srcDir = path.join(__dirname, '..', 'src');
|
|
||||||
const files = getTypeScriptFiles(srcDir);
|
|
||||||
|
|
||||||
console.log(`Found ${files.length} TypeScript files`);
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
fixImportsInFile(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Done');
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
@ -1,60 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// Get all TypeScript files in a directory recursively
|
|
||||||
function getTypeScriptFiles(dir) {
|
|
||||||
const files = [];
|
|
||||||
|
|
||||||
function traverse(currentDir) {
|
|
||||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = path.join(currentDir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
traverse(fullPath);
|
|
||||||
} else if (entry.isFile() && entry.name.endsWith('.ts')) {
|
|
||||||
files.push(fullPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
traverse(dir);
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fix imports in a file
|
|
||||||
function fixImportsInFile(filePath) {
|
|
||||||
console.log(`Processing ${filePath}`);
|
|
||||||
let content = fs.readFileSync(filePath, 'utf8');
|
|
||||||
|
|
||||||
// Regular expression to match relative imports without file extensions
|
|
||||||
const importRegex = /(import\s+(?:[^'"]*\s+from\s+)?['"])(\.\.[^'"]*?)(['"])/g;
|
|
||||||
|
|
||||||
// Add .js extension to relative imports
|
|
||||||
content = content.replace(importRegex, (match, start, importPath, end) => {
|
|
||||||
// Don't add extension if it already has one or ends with a directory
|
|
||||||
if (importPath.endsWith('.js') || importPath.endsWith('/')) {
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
return `${start}${importPath}.js${end}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(filePath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Main function
|
|
||||||
function main() {
|
|
||||||
const srcDir = path.join(__dirname, '..', 'src');
|
|
||||||
const files = getTypeScriptFiles(srcDir);
|
|
||||||
|
|
||||||
console.log(`Found ${files.length} TypeScript files`);
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
fixImportsInFile(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Done');
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
|
||||||
57
src/commands/join.rs
Normal file
57
src/commands/join.rs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// src/commands/join.rs
|
||||||
|
|
||||||
|
use super::{CommandHandler, SlashCommand}; // Import from parent mod.rs
|
||||||
|
use crate::utils::get_voice_state; // Import utility
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use lavalink_rs::LavalinkClient;
|
||||||
|
use serenity::{
|
||||||
|
builder::{CreateApplicationCommandOption, EditInteractionResponse}, // Need EditInteractionResponse
|
||||||
|
client::Context,
|
||||||
|
model::prelude::{
|
||||||
|
interaction::application_command::ApplicationCommandInteraction,
|
||||||
|
Id // Import Id directly if needed, though GuildId/ChannelId are often used
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use tracing::instrument;
|
||||||
|
use futures::future::BoxFuture; // Import BoxFuture
|
||||||
|
|
||||||
|
// Command metadata and registration
|
||||||
|
inventory::submit! {
|
||||||
|
SlashCommand {
|
||||||
|
name: "join",
|
||||||
|
description: "Makes the bot join your current voice channel.",
|
||||||
|
options: || Vec::new(), // No options for /join
|
||||||
|
handler: handle_join,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The actual logic for the /join command
|
||||||
|
#[instrument(skip(ctx, interaction, lavalink))]
|
||||||
|
fn handle_join<'a>(
|
||||||
|
ctx: &'a Context,
|
||||||
|
interaction: &'a ApplicationCommandInteraction,
|
||||||
|
lavalink: &'a LavalinkClient,
|
||||||
|
) -> BoxFuture<'a, Result<()>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let guild_id = interaction.guild_id.context("Command must be used in a guild")?;
|
||||||
|
let user_id = interaction.user.id;
|
||||||
|
|
||||||
|
// Find the user's voice channel using the utility function
|
||||||
|
let voice_state = get_voice_state(ctx, guild_id, user_id).await
|
||||||
|
.context("Failed to get your voice state. Are you in a channel?")?;
|
||||||
|
|
||||||
|
let channel_id = voice_state.channel_id
|
||||||
|
.context("You are not currently in a voice channel.")?;
|
||||||
|
|
||||||
|
// Request Lavalink create a session (actual connection happens via gateway events)
|
||||||
|
lavalink.create_session(guild_id, channel_id).await?;
|
||||||
|
|
||||||
|
// Edit the deferred response to confirm action
|
||||||
|
interaction.edit_original_response(&ctx.http,
|
||||||
|
EditInteractionResponse::new()
|
||||||
|
.content(format!("Joining <#{}>...", channel_id))
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,127 +0,0 @@
|
|||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
PermissionFlagsBits,
|
|
||||||
ChannelType,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
GuildMember,
|
|
||||||
VoiceBasedChannel,
|
|
||||||
} from "discord.js";
|
|
||||||
import logger from "../utils/logger.js";
|
|
||||||
import { BotClient } from "../index.js";
|
|
||||||
import { Player } from "shoukaku";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("join")
|
|
||||||
.setDescription("Joins your current voice channel"),
|
|
||||||
async execute(_interaction: ChatInputCommandInteraction, _client: BotClient) {
|
|
||||||
// Ensure command is run in a guild
|
|
||||||
if (!_interaction.guildId || !_interaction.guild || !_interaction.channelId) {
|
|
||||||
return _interaction
|
|
||||||
.reply({ content: "This command can only be used in a server.", ephemeral: true })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
// Ensure _interaction.member is a GuildMember
|
|
||||||
if (!(_interaction.member instanceof GuildMember)) {
|
|
||||||
return _interaction
|
|
||||||
.reply({ content: "Could not determine your voice channel.", ephemeral: true })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use ephemeral deferral
|
|
||||||
await _interaction.deferReply({ ephemeral: true });
|
|
||||||
|
|
||||||
const member = _interaction.member; // Already checked it's GuildMember
|
|
||||||
const voiceChannel = member?.voice?.channel;
|
|
||||||
|
|
||||||
// 1. Check if user is in a voice channel
|
|
||||||
if (!voiceChannel) {
|
|
||||||
return _interaction.editReply("You need to be in a voice channel to use this command!");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type assertion for voiceChannel after check
|
|
||||||
const currentVoiceChannel = voiceChannel as VoiceBasedChannel;
|
|
||||||
|
|
||||||
// 2. Check bot permissions
|
|
||||||
const permissions = currentVoiceChannel.permissionsFor(_client.user!);
|
|
||||||
if (!permissions?.has(PermissionFlagsBits.Connect)) {
|
|
||||||
return _interaction.editReply("I need permission to **connect** to your voice channel!");
|
|
||||||
}
|
|
||||||
if (!permissions?.has(PermissionFlagsBits.Speak)) {
|
|
||||||
return _interaction.editReply("I need permission to **speak** in your voice channel!");
|
|
||||||
}
|
|
||||||
// Ensure it's a voice channel (not stage, etc.)
|
|
||||||
if (currentVoiceChannel.type !== ChannelType.GuildVoice) {
|
|
||||||
return _interaction.editReply("I can only join standard voice channels.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the initialized Shoukaku instance from the _client object
|
|
||||||
const shoukaku = _client.shoukaku;
|
|
||||||
if (!shoukaku) {
|
|
||||||
logger.error("Shoukaku instance not found on _client object!");
|
|
||||||
return _interaction.editReply("The music player is not ready yet. Please try again shortly.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get or create the player and connect using Shoukaku
|
|
||||||
let player: Player | undefined = shoukaku.players.get(_interaction.guildId);
|
|
||||||
|
|
||||||
// First, ensure clean state by disconnecting if already connected
|
|
||||||
if (player) {
|
|
||||||
try {
|
|
||||||
logger.info(`Destroying existing player for guild ${_interaction.guildId} before reconnecting`);
|
|
||||||
await player.destroy();
|
|
||||||
player = undefined;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Error destroying existing player: ${error}`);
|
|
||||||
// Continue with connection attempt anyway
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to join voice channel with retry logic
|
|
||||||
let attempts = 0;
|
|
||||||
const maxAttempts = 3;
|
|
||||||
|
|
||||||
while (attempts < maxAttempts) {
|
|
||||||
attempts++;
|
|
||||||
try {
|
|
||||||
// Wait a short time between retries to allow Discord's voice state to update
|
|
||||||
if (attempts > 1) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
logger.info(`Attempt ${attempts} to join voice channel ${currentVoiceChannel.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
player = await shoukaku.joinVoiceChannel({
|
|
||||||
guildId: _interaction.guildId,
|
|
||||||
channelId: currentVoiceChannel.id,
|
|
||||||
shardId: _interaction.guild.shardId,
|
|
||||||
deaf: true // Set to true to avoid listening to voice data, saves bandwidth
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Created player and connected to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${_interaction.guild.name} (${_interaction.guildId})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Connection was successful
|
|
||||||
await _interaction.editReply(`Joined ${currentVoiceChannel.name}! Ready to play music.`);
|
|
||||||
return;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(
|
|
||||||
`Attempt ${attempts}: Failed to connect to voice channel for guild ${_interaction.guildId}: ${errorMessage}`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up any partial connections on failure
|
|
||||||
try {
|
|
||||||
await shoukaku.leaveVoiceChannel(_interaction.guildId);
|
|
||||||
} catch (leaveError) {
|
|
||||||
// Ignore leave errors
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempts === maxAttempts) {
|
|
||||||
return _interaction.editReply(`Failed to join voice channel after ${maxAttempts} attempts. Please try again later.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
49
src/commands/leave.rs
Normal file
49
src/commands/leave.rs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// src/commands/leave.rs
|
||||||
|
|
||||||
|
use super::{CommandHandler, SlashCommand};
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use lavalink_rs::LavalinkClient;
|
||||||
|
use serenity::{
|
||||||
|
builder::{CreateApplicationCommandOption, EditInteractionResponse},
|
||||||
|
client::Context,
|
||||||
|
model::prelude::interaction::application_command::ApplicationCommandInteraction,
|
||||||
|
};
|
||||||
|
use tracing::instrument;
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
|
||||||
|
inventory::submit! {
|
||||||
|
SlashCommand {
|
||||||
|
name: "leave",
|
||||||
|
description: "Makes the bot leave the current voice channel.",
|
||||||
|
options: || Vec::new(), // No options
|
||||||
|
handler: handle_leave,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(ctx, interaction, lavalink))]
|
||||||
|
fn handle_leave<'a>(
|
||||||
|
ctx: &'a Context,
|
||||||
|
interaction: &'a ApplicationCommandInteraction,
|
||||||
|
lavalink: &'a LavalinkClient,
|
||||||
|
) -> BoxFuture<'a, Result<()>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let guild_id = interaction.guild_id.context("Command must be used in a guild")?;
|
||||||
|
|
||||||
|
// Check if connected before trying to leave
|
||||||
|
if lavalink.get_player(guild_id).is_none() {
|
||||||
|
interaction.edit_original_response(&ctx.http,
|
||||||
|
EditInteractionResponse::new().content("Bot is not in a voice channel.")
|
||||||
|
).await?;
|
||||||
|
return Ok(()); // Not an error, just inform user
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy the Lavalink session for this guild
|
||||||
|
lavalink.destroy_session(guild_id).await?;
|
||||||
|
|
||||||
|
interaction.edit_original_response(&ctx.http,
|
||||||
|
EditInteractionResponse::new().content("Left the voice channel.")
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
ChatInputCommandInteraction, // Import the specific _interaction type
|
|
||||||
GuildMember, // Import GuildMember type
|
|
||||||
} from "discord.js";
|
|
||||||
import logger from "../utils/logger.js"; // Use default import
|
|
||||||
import { BotClient } from "../index.js"; // Import the BotClient interface
|
|
||||||
// No need to import Player explicitly if we just check connection
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Use export default for ES Modules
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("leave")
|
|
||||||
.setDescription("Leaves the current voice channel"),
|
|
||||||
async execute(_interaction: ChatInputCommandInteraction, _client: BotClient) {
|
|
||||||
// Add types
|
|
||||||
// Ensure command is run in a guild
|
|
||||||
if (!_interaction.guildId || !_interaction.guild) {
|
|
||||||
return _interaction
|
|
||||||
.reply({ content: "This command can only be used in a server.", ephemeral: true })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
// Ensure _interaction.member is a GuildMember (optional, but good practice)
|
|
||||||
if (!(_interaction.member instanceof GuildMember)) {
|
|
||||||
return _interaction
|
|
||||||
.reply({ content: "Could not verify your membership.", ephemeral: true })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use ephemeral deferral
|
|
||||||
await _interaction.deferReply({ ephemeral: true });
|
|
||||||
|
|
||||||
// Get the Shoukaku instance
|
|
||||||
const shoukaku = _client.shoukaku;
|
|
||||||
if (!shoukaku) {
|
|
||||||
logger.error("Shoukaku instance not found on _client object!");
|
|
||||||
return _interaction.editReply("The music player is not ready yet.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if a connection exists for this guild
|
|
||||||
const connection = shoukaku.connections.get(_interaction.guildId);
|
|
||||||
if (!connection || !connection.channelId) {
|
|
||||||
return _interaction.editReply("I am not currently in a voice channel!");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: Check if the user is in the same channel as the bot
|
|
||||||
// const memberVoiceChannelId = _interaction.member.voice.channelId;
|
|
||||||
// if (memberVoiceChannelId !== connection.channelId) {
|
|
||||||
// return _interaction.editReply('You need to be in the same voice channel as me to make me leave!');
|
|
||||||
// }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const channelId = connection.channelId; // Get channel ID from connection
|
|
||||||
const channel = await _client.channels.fetch(channelId).catch(() => null); // Fetch channel for name
|
|
||||||
const channelName = channel && channel.isVoiceBased() ? channel.name : `ID: ${channelId}`; // Get channel name if possible
|
|
||||||
|
|
||||||
// Use Shoukaku's leave method - this destroys player and connection
|
|
||||||
await shoukaku.leaveVoiceChannel(_interaction.guildId);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Left voice channel ${channelName} in guild ${_interaction.guild.name} (${_interaction.guildId}) by user ${_interaction.user.tag}`,
|
|
||||||
);
|
|
||||||
await _interaction.editReply(`Left ${channelName}.`);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Type error as unknown
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(
|
|
||||||
`Error leaving voice channel for guild ${_interaction.guildId}: ${errorMessage}`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
// Attempt to reply even if leave failed partially
|
|
||||||
await _interaction
|
|
||||||
.editReply("An error occurred while trying to leave the voice channel.")
|
|
||||||
.catch((e: unknown) => {
|
|
||||||
// Type catch error
|
|
||||||
const replyErrorMsg = e instanceof Error ? e.message : String(e);
|
|
||||||
logger.error(`Failed to send error reply for leave command: ${replyErrorMsg}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js";
|
|
||||||
// No need to import BotClient if not used directly in execute
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Use export default for ES Modules
|
|
||||||
data: new SlashCommandBuilder().setName("ping").setDescription("Replies with Pong!"),
|
|
||||||
async execute(_interaction: ChatInputCommandInteraction) {
|
|
||||||
// Add _interaction type
|
|
||||||
// Calculate latency (optional but common for ping commands)
|
|
||||||
const sent = await _interaction.reply({
|
|
||||||
content: "Pinging...",
|
|
||||||
fetchReply: true,
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
const latency = sent.createdTimestamp - _interaction.createdTimestamp;
|
|
||||||
const wsPing = _interaction.client.ws.ping; // WebSocket heartbeat ping
|
|
||||||
|
|
||||||
await _interaction.editReply(
|
|
||||||
`Pong! 🏓\nRoundtrip latency: ${latency}ms\nWebSocket Ping: ${wsPing}ms`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
168
src/commands/play.rs
Normal file
168
src/commands/play.rs
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
// src/commands/play.rs
|
||||||
|
|
||||||
|
use super::{CommandHandler, SlashCommand};
|
||||||
|
use crate::utils::get_voice_state; // Needed if join logic is integrated
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use lavalink_rs::{
|
||||||
|
model::search::{SearchResult, SearchType},
|
||||||
|
LavalinkClient
|
||||||
|
};
|
||||||
|
use serenity::{
|
||||||
|
builder::{CreateApplicationCommandOption, EditInteractionResponse},
|
||||||
|
client::Context,
|
||||||
|
model::prelude::{
|
||||||
|
interaction::application_command::{
|
||||||
|
ApplicationCommandInteraction, CommandDataOptionValue,
|
||||||
|
ApplicationCommandOptionType,
|
||||||
|
},
|
||||||
|
Id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use tracing::{info, instrument};
|
||||||
|
use url::Url;
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
use tokio::time::Duration; // Needed for sleep if join logic integrated
|
||||||
|
|
||||||
|
// Command metadata and registration
|
||||||
|
inventory::submit! {
|
||||||
|
SlashCommand {
|
||||||
|
name: "play",
|
||||||
|
description: "Plays a song from a URL or search query.",
|
||||||
|
options: play_options, // Function pointer to build options
|
||||||
|
handler: handle_play,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to define options for the /play command
|
||||||
|
fn play_options() -> Vec<CreateApplicationCommandOption> {
|
||||||
|
vec![
|
||||||
|
CreateApplicationCommandOption::new(
|
||||||
|
ApplicationCommandOptionType::String,
|
||||||
|
"query",
|
||||||
|
"The URL or search query for the song."
|
||||||
|
)
|
||||||
|
.required(true)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Helper to join channel if not already in one (avoids duplicating join logic)
|
||||||
|
// Note: This is slightly simplified, might need more robust error handling from join
|
||||||
|
async fn ensure_connected(
|
||||||
|
ctx: &Context,
|
||||||
|
interaction: &ApplicationCommandInteraction,
|
||||||
|
guild_id: serenity::model::id::GuildId,
|
||||||
|
lavalink: &LavalinkClient,
|
||||||
|
) -> Result<()> {
|
||||||
|
if lavalink.get_player(guild_id).is_some() {
|
||||||
|
return Ok(()); // Already connected
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Play command used but bot not connected. Attempting auto-join.");
|
||||||
|
let user_id = interaction.user.id;
|
||||||
|
let voice_state = get_voice_state(ctx, guild_id, user_id).await
|
||||||
|
.context("Bot not connected & failed to get your voice state. Are you in a channel?")?;
|
||||||
|
let channel_id = voice_state.channel_id
|
||||||
|
.context("Bot not connected & you're not in a voice channel. Use /join first.")?;
|
||||||
|
|
||||||
|
lavalink.create_session(guild_id, channel_id).await?;
|
||||||
|
// Give a moment for connection events to process
|
||||||
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
|
||||||
|
// Check if connection succeeded
|
||||||
|
lavalink.get_player(guild_id)
|
||||||
|
.context("Failed to establish voice connection after attempting to join.")?;
|
||||||
|
|
||||||
|
info!("Auto-join successful for play command.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// The actual logic for the /play command
|
||||||
|
#[instrument(skip(ctx, interaction, lavalink))]
|
||||||
|
fn handle_play<'a>(
|
||||||
|
ctx: &'a Context,
|
||||||
|
interaction: &'a ApplicationCommandInteraction,
|
||||||
|
lavalink: &'a LavalinkClient,
|
||||||
|
) -> BoxFuture<'a, Result<()>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let guild_id = interaction.guild_id.context("Command must be used in a guild")?;
|
||||||
|
|
||||||
|
// Ensure the bot is connected, joining if necessary
|
||||||
|
ensure_connected(ctx, interaction, guild_id, lavalink).await?;
|
||||||
|
|
||||||
|
// Get the query string from the interaction options
|
||||||
|
let query = interaction.data.options.iter()
|
||||||
|
.find(|opt| opt.name == "query")
|
||||||
|
.and_then(|opt| opt.value.as_ref())
|
||||||
|
.and_then(|v| v.as_str()) // Use as_str for simplicity
|
||||||
|
.context("Missing 'query' option")?;
|
||||||
|
|
||||||
|
info!("Searching for: {}", query);
|
||||||
|
|
||||||
|
// Determine search type
|
||||||
|
let search_type = if Url::parse(query).is_ok() {
|
||||||
|
SearchType::Track(query.to_string())
|
||||||
|
} else {
|
||||||
|
SearchType::YouTube(query.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perform the search
|
||||||
|
let search_result = lavalink.search_tracks(search_type).await?;
|
||||||
|
|
||||||
|
// Process results (simplified from previous version for brevity)
|
||||||
|
let (tracks_to_play, response_prefix) = match search_result {
|
||||||
|
SearchResult::Track(track) => {
|
||||||
|
info!("Found track: {}", track.info.title);
|
||||||
|
(vec![track], "Now playing:".to_string())
|
||||||
|
}
|
||||||
|
SearchResult::Playlist(playlist) => {
|
||||||
|
info!("Found playlist: {} ({} tracks)", playlist.info.name, playlist.tracks.len());
|
||||||
|
(playlist.tracks, format!("Queued playlist **{}**: Playing first track:", playlist.info.name))
|
||||||
|
}
|
||||||
|
SearchResult::Search(tracks) => {
|
||||||
|
if let Some(track) = tracks.first() {
|
||||||
|
info!("Found search result: {}", track.info.title);
|
||||||
|
(vec![track.clone()], "Now playing:".to_string())
|
||||||
|
} else { (vec![], String::new()) } // No tracks found
|
||||||
|
}
|
||||||
|
SearchResult::Empty => (vec![], String::new()), // No tracks found
|
||||||
|
SearchResult::LoadFailed(e) => {
|
||||||
|
return Err(anyhow::anyhow!("Failed to load track: {}", e.message));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if tracks_to_play.is_empty() {
|
||||||
|
interaction.edit_original_response(&ctx.http,
|
||||||
|
EditInteractionResponse::new().content(format!("No results found for: `{}`", query))
|
||||||
|
).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the player (should exist after ensure_connected)
|
||||||
|
let player = lavalink.get_player(guild_id)
|
||||||
|
.context("Player disappeared unexpectedly after connecting.")?;
|
||||||
|
|
||||||
|
|
||||||
|
// Play first track, queue the rest
|
||||||
|
let first_track = tracks_to_play.first().unwrap(); // Safe due to is_empty check
|
||||||
|
player.play_track(&first_track.track).await?;
|
||||||
|
|
||||||
|
let mut final_response = format!("{} **{}** by **{}**",
|
||||||
|
response_prefix, first_track.info.title, first_track.info.author);
|
||||||
|
|
||||||
|
if tracks_to_play.len() > 1 {
|
||||||
|
for track in tracks_to_play.iter().skip(1) {
|
||||||
|
player.queue(&track.track).await?;
|
||||||
|
}
|
||||||
|
final_response.push_str(&format!("\n...and queued {} other tracks.", tracks_to_play.len() - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update interaction response
|
||||||
|
interaction.edit_original_response(&ctx.http,
|
||||||
|
EditInteractionResponse::new().content(final_response)
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,445 +0,0 @@
|
|||||||
import {
|
|
||||||
SlashCommandBuilder,
|
|
||||||
SlashCommandStringOption, // Import for typing _options
|
|
||||||
PermissionFlagsBits,
|
|
||||||
ChannelType,
|
|
||||||
EmbedBuilder,
|
|
||||||
ChatInputCommandInteraction,
|
|
||||||
GuildMember,
|
|
||||||
VoiceBasedChannel,
|
|
||||||
} from "discord.js";
|
|
||||||
import logger from "../utils/logger.js";
|
|
||||||
import { BotClient } from "../index.js";
|
|
||||||
// Import necessary Shoukaku types - LavalinkResponse might need a local definition if not exported
|
|
||||||
import { Player, Node, Track, SearchResult, Connection } from "shoukaku";
|
|
||||||
|
|
||||||
// Define the structure of the Lavalink V4 response (if not directly available from shoukaku types)
|
|
||||||
// Based on https://lavalink.dev/api/rest.html#load-tracks
|
|
||||||
type LavalinkLoadType = "track" | "playlist" | "search" | "empty" | "error";
|
|
||||||
|
|
||||||
interface LavalinkResponse {
|
|
||||||
loadType: LavalinkLoadType;
|
|
||||||
data: any; // Data structure varies based on loadType
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LavalinkErrorData {
|
|
||||||
message: string;
|
|
||||||
severity: string;
|
|
||||||
cause: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LavalinkPlaylistInfo {
|
|
||||||
name: string;
|
|
||||||
selectedTrack?: number; // Optional index of the selected track within the playlist
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LavalinkPlaylistData {
|
|
||||||
info: LavalinkPlaylistInfo;
|
|
||||||
pluginInfo: any; // Or specific type if known
|
|
||||||
tracks: Track[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export: Extend Player type locally to add queue and textChannelId
|
|
||||||
export interface GuildPlayer extends Player {
|
|
||||||
queue: TrackWithRequester[];
|
|
||||||
textChannelId?: string; // Optional: Store text channel ID for messages
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export: Define TrackWithRequester
|
|
||||||
export interface TrackWithRequester extends Track {
|
|
||||||
// Ensure encoded is strictly string if extending base Track which might have it optional
|
|
||||||
encoded: string;
|
|
||||||
requester: {
|
|
||||||
id: string;
|
|
||||||
tag: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export: Helper function to start playback if possible
|
|
||||||
export async function playNext(player: GuildPlayer, _interaction: ChatInputCommandInteraction) {
|
|
||||||
// Check if player is still valid (might have been destroyed)
|
|
||||||
const shoukaku = (_interaction.client as BotClient).shoukaku;
|
|
||||||
if (!shoukaku?.players.has(player.guildId)) {
|
|
||||||
logger.warn(`playNext called for destroyed player in guild ${player.guildId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player.track || player.queue.length === 0) {
|
|
||||||
return; // Already playing or queue is empty
|
|
||||||
}
|
|
||||||
const nextTrack = player.queue.shift();
|
|
||||||
if (!nextTrack) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Check if user provided an OAuth token (could be stored in a database or env variable)
|
|
||||||
const oauthToken = process.env.YOUTUBE_OAUTH_TOKEN;
|
|
||||||
const userData = oauthToken ? { "oauth-token": oauthToken } : undefined;
|
|
||||||
// Fix: Correct usage for playTrack based on Player.ts
|
|
||||||
await player.playTrack({ track: { encoded: nextTrack.encoded, userData: userData } });
|
|
||||||
// logger.info(`Started playing: ${nextTrack.info.title} in guild ${player.guildId}`);
|
|
||||||
} catch (playError: unknown) {
|
|
||||||
const errorMsg = playError instanceof Error ? playError.message : String(playError);
|
|
||||||
logger.error(
|
|
||||||
`Error playing track ${nextTrack.info.title} in guild ${player.guildId}: ${errorMsg}`,
|
|
||||||
);
|
|
||||||
// Try to send error message to the stored text channel
|
|
||||||
const channel = _interaction.guild?.channels.cache.get(
|
|
||||||
player.textChannelId || _interaction.channelId,
|
|
||||||
);
|
|
||||||
if (channel?.isTextBased()) {
|
|
||||||
// Fix: Check if e is Error before accessing message
|
|
||||||
channel
|
|
||||||
.send(`Error playing track: ${nextTrack.info.title}. Reason: ${errorMsg}`)
|
|
||||||
.catch((e: unknown) => {
|
|
||||||
const sendErrorMsg = e instanceof Error ? e.message : String(e);
|
|
||||||
logger.error(`Failed to send play error message: ${sendErrorMsg}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Try playing the next track if available
|
|
||||||
await playNext(player, _interaction);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName("play")
|
|
||||||
.setDescription("Plays audio from a URL or search query")
|
|
||||||
.addStringOption(
|
|
||||||
(
|
|
||||||
option: SlashCommandStringOption, // Type option
|
|
||||||
) =>
|
|
||||||
option
|
|
||||||
.setName("query")
|
|
||||||
.setDescription("The URL or search term for the song/playlist")
|
|
||||||
.setRequired(true),
|
|
||||||
)
|
|
||||||
.addStringOption(
|
|
||||||
(
|
|
||||||
option: SlashCommandStringOption, // Type option
|
|
||||||
) =>
|
|
||||||
option
|
|
||||||
.setName("source")
|
|
||||||
.setDescription("Specify the search source (defaults to YouTube Music)")
|
|
||||||
.setRequired(false)
|
|
||||||
.addChoices(
|
|
||||||
{ name: "YouTube Music", value: "youtubemusic" },
|
|
||||||
{ name: "YouTube", value: "youtube" },
|
|
||||||
{ name: "SoundCloud", value: "soundcloud" },
|
|
||||||
// Add other sources like 'spotify' if supported by Lavalink plugins
|
|
||||||
),
|
|
||||||
),
|
|
||||||
async execute(_interaction: ChatInputCommandInteraction, _client: BotClient) {
|
|
||||||
// Ensure command is run in a guild
|
|
||||||
if (!_interaction.guildId || !_interaction.guild || !_interaction.channelId) {
|
|
||||||
return _interaction
|
|
||||||
.reply({ content: "This command can only be used in a server.", ephemeral: true })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
if (!(_interaction.member instanceof GuildMember)) {
|
|
||||||
return _interaction
|
|
||||||
.reply({ content: "Could not determine your voice channel.", ephemeral: true })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
await _interaction.deferReply(); // Defer reply immediately
|
|
||||||
|
|
||||||
const member = _interaction.member;
|
|
||||||
const voiceChannel = member?.voice?.channel;
|
|
||||||
const query = _interaction.options.getString("query", true); // Required option
|
|
||||||
const source = _interaction.options.getString("source"); // Optional
|
|
||||||
|
|
||||||
// 1. Check if user is in a voice channel
|
|
||||||
if (!voiceChannel) {
|
|
||||||
return _interaction.editReply("You need to be in a voice channel to play music!");
|
|
||||||
}
|
|
||||||
const currentVoiceChannel = voiceChannel as VoiceBasedChannel;
|
|
||||||
|
|
||||||
// 2. Check bot permissions
|
|
||||||
const permissions = currentVoiceChannel.permissionsFor(_client.user!);
|
|
||||||
if (!permissions?.has(PermissionFlagsBits.Connect)) {
|
|
||||||
return _interaction.editReply("I need permission to **connect** to your voice channel!");
|
|
||||||
}
|
|
||||||
if (!permissions?.has(PermissionFlagsBits.Speak)) {
|
|
||||||
return _interaction.editReply("I need permission to **speak** in your voice channel!");
|
|
||||||
}
|
|
||||||
if (currentVoiceChannel.type !== ChannelType.GuildVoice) {
|
|
||||||
return _interaction.editReply("I can only join standard voice channels.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Shoukaku instance
|
|
||||||
const shoukaku = _client.shoukaku;
|
|
||||||
if (!shoukaku) {
|
|
||||||
logger.error("Shoukaku instance not found on _client object!");
|
|
||||||
return _interaction.editReply("The music player is not ready yet. Please try again shortly.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let player: GuildPlayer | undefined; // Declare player variable outside try block
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 3. Get or create player/connection
|
|
||||||
player = shoukaku.players.get(_interaction.guildId) as GuildPlayer | undefined;
|
|
||||||
const connection = shoukaku.connections.get(_interaction.guildId);
|
|
||||||
|
|
||||||
// Check if we need to join or move to a different channel
|
|
||||||
if (!player || !connection || connection.channelId !== currentVoiceChannel.id) {
|
|
||||||
// If existing player, destroy it for a clean slate
|
|
||||||
if (player) {
|
|
||||||
try {
|
|
||||||
logger.info(`Destroying existing player for guild ${_interaction.guildId} before reconnecting`);
|
|
||||||
await player.destroy();
|
|
||||||
player = undefined;
|
|
||||||
} catch (error) {
|
|
||||||
logger.warn(`Error destroying existing player: ${error}`);
|
|
||||||
// Continue with connection attempt anyway
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to join voice channel with retry logic
|
|
||||||
let attempts = 0;
|
|
||||||
const maxAttempts = 3;
|
|
||||||
let joinSuccess = false;
|
|
||||||
|
|
||||||
while (attempts < maxAttempts && !joinSuccess) {
|
|
||||||
attempts++;
|
|
||||||
try {
|
|
||||||
// Wait a short time between retries to allow Discord's voice state to update
|
|
||||||
if (attempts > 1) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
logger.info(`Attempt ${attempts} to join voice channel ${currentVoiceChannel.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
player = (await shoukaku.joinVoiceChannel({
|
|
||||||
guildId: _interaction.guildId,
|
|
||||||
channelId: currentVoiceChannel.id,
|
|
||||||
shardId: _interaction.guild.shardId,
|
|
||||||
deaf: true // Set to true to avoid listening to voice data, saves bandwidth
|
|
||||||
})) as GuildPlayer;
|
|
||||||
|
|
||||||
// Initialize queue if it's a new player
|
|
||||||
if (!player.queue) {
|
|
||||||
player.queue = [];
|
|
||||||
}
|
|
||||||
player.textChannelId = _interaction.channelId; // Store text channel context
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Joined/Moved to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) for play command.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
joinSuccess = true;
|
|
||||||
} catch (joinError: unknown) {
|
|
||||||
const errorMsg = joinError instanceof Error ? joinError.message : String(joinError);
|
|
||||||
logger.error(
|
|
||||||
`Attempt ${attempts}: Failed to join voice channel for guild ${_interaction.guildId}: ${errorMsg}`,
|
|
||||||
joinError,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Clean up any partial connections on failure
|
|
||||||
try {
|
|
||||||
await shoukaku.leaveVoiceChannel(_interaction.guildId);
|
|
||||||
} catch (leaveError) {
|
|
||||||
// Ignore leave errors
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempts === maxAttempts) {
|
|
||||||
return _interaction.editReply(
|
|
||||||
"Failed to join the voice channel after multiple attempts. Please try again later."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// We already have a player connected to the right channel
|
|
||||||
// Ensure queue exists if player was retrieved
|
|
||||||
if (!player.queue) {
|
|
||||||
player.queue = [];
|
|
||||||
}
|
|
||||||
// Update text channel context if needed
|
|
||||||
player.textChannelId = _interaction.channelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Determine search identifier based on query and source
|
|
||||||
let identifier: string;
|
|
||||||
const isUrl = query.startsWith("http://") || query.startsWith("https://");
|
|
||||||
|
|
||||||
if (isUrl) {
|
|
||||||
identifier = query; // Use URL directly
|
|
||||||
} else {
|
|
||||||
// Prepend search prefix based on source or default
|
|
||||||
switch (source) {
|
|
||||||
case "youtube":
|
|
||||||
identifier = `ytsearch:${query}`;
|
|
||||||
break;
|
|
||||||
case "soundcloud":
|
|
||||||
identifier = `scsearch:${query}`;
|
|
||||||
break;
|
|
||||||
case "youtubemusic":
|
|
||||||
default: // Default to YouTube Music
|
|
||||||
identifier = `ytmsearch:${query}`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug(`Constructed identifier: ${identifier}`);
|
|
||||||
|
|
||||||
// 5. Search for tracks using Lavalink REST API via an ideal node
|
|
||||||
const node = shoukaku.getIdealNode();
|
|
||||||
if (!node) {
|
|
||||||
throw new Error("No available Lavalink node.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the correct return type (LavalinkResponse) and check for undefined
|
|
||||||
const searchResult: LavalinkResponse | undefined = await node.rest.resolve(identifier);
|
|
||||||
|
|
||||||
if (!searchResult) {
|
|
||||||
throw new Error("REST resolve returned undefined or null.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 6. Process search results and add to queue
|
|
||||||
const responseEmbed = new EmbedBuilder().setColor("#0099ff");
|
|
||||||
let tracksToAdd: TrackWithRequester[] = [];
|
|
||||||
|
|
||||||
// Switch using string literals based on Lavalink V4 load types
|
|
||||||
switch (searchResult.loadType) {
|
|
||||||
case "track": {
|
|
||||||
// Use 'track'
|
|
||||||
const track = searchResult.data as Track;
|
|
||||||
// Ensure track and encoded exist before pushing
|
|
||||||
if (!track?.encoded) throw new Error("Loaded track is missing encoded data.");
|
|
||||||
tracksToAdd.push({
|
|
||||||
...track,
|
|
||||||
encoded: track.encoded, // Explicitly include non-null encoded
|
|
||||||
requester: { id: _interaction.user.id, tag: _interaction.user.tag },
|
|
||||||
});
|
|
||||||
responseEmbed
|
|
||||||
.setTitle("Track Added to Queue")
|
|
||||||
.setDescription(`[${track.info.title}](${track.info.uri})`)
|
|
||||||
.addFields({
|
|
||||||
name: "Position in queue",
|
|
||||||
value: `${player?.queue?.length ?? 0 + 1}`, // Add null checks
|
|
||||||
inline: true,
|
|
||||||
});
|
|
||||||
if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl); // Use artworkUrl
|
|
||||||
logger.info(`Adding track: ${track.info.title} (Guild: ${_interaction.guildId})`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "search": {
|
|
||||||
// Use 'search'
|
|
||||||
const tracks = searchResult.data as Track[]; // Data is an array of tracks
|
|
||||||
if (!tracks || tracks.length === 0) throw new Error("Search returned no results.");
|
|
||||||
// Fix: Assign track AFTER the check
|
|
||||||
const track = tracks[0];
|
|
||||||
if (!track?.encoded) throw new Error("Searched track is missing encoded data.");
|
|
||||||
tracksToAdd.push({
|
|
||||||
...track,
|
|
||||||
encoded: track.encoded, // Explicitly include non-null encoded
|
|
||||||
requester: { id: _interaction.user.id, tag: _interaction.user.tag },
|
|
||||||
});
|
|
||||||
responseEmbed
|
|
||||||
.setTitle("Track Added to Queue")
|
|
||||||
.setDescription(`[${track.info.title}](${track.info.uri})`)
|
|
||||||
.addFields({
|
|
||||||
name: "Position in queue",
|
|
||||||
value: `${player?.queue?.length ?? 0 + 1}`, // Add null checks
|
|
||||||
inline: true,
|
|
||||||
});
|
|
||||||
if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl);
|
|
||||||
logger.info(
|
|
||||||
`Adding track from search: ${track.info.title} (Guild: ${_interaction.guildId})`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "playlist": {
|
|
||||||
// Use 'playlist'
|
|
||||||
const playlistData = searchResult.data as LavalinkPlaylistData; // Cast to correct structure
|
|
||||||
const playlistInfo = playlistData.info;
|
|
||||||
const playlistTracks = playlistData.tracks;
|
|
||||||
// Fix: Filter out tracks without encoded string and assert non-null for map
|
|
||||||
tracksToAdd = playlistTracks
|
|
||||||
.filter((track) => !!track.encoded) // Ensure encoded exists
|
|
||||||
.map((track) => ({
|
|
||||||
...track,
|
|
||||||
encoded: track.encoded!, // Add non-null assertion
|
|
||||||
requester: { id: _interaction.user.id, tag: _interaction.user.tag },
|
|
||||||
}));
|
|
||||||
if (tracksToAdd.length === 0) throw new Error("Playlist contained no playable tracks.");
|
|
||||||
// Fix: Use direct optional chaining on array access
|
|
||||||
responseEmbed
|
|
||||||
.setTitle("Playlist Added to Queue")
|
|
||||||
.setDescription(
|
|
||||||
`**[${playlistInfo.name}](${identifier})** (${tracksToAdd.length} tracks)`,
|
|
||||||
) // Use filtered length
|
|
||||||
.addFields({
|
|
||||||
name: "Starting track",
|
|
||||||
value: `[${tracksToAdd[0]?.info?.title}](${tracksToAdd[0]?.info?.uri})`,
|
|
||||||
}); // Use direct optional chaining
|
|
||||||
logger.info(
|
|
||||||
`Adding playlist: ${playlistInfo.name} (${tracksToAdd.length} tracks) (Guild: ${_interaction.guildId})`,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "empty": // Use 'empty'
|
|
||||||
await _interaction.editReply(`No results found for "${query}".`);
|
|
||||||
// Optional: Leave if queue is empty?
|
|
||||||
// if (player && !player.track && player.queue.length === 0) {
|
|
||||||
// await shoukaku.leaveVoiceChannel(_interaction.guildId);
|
|
||||||
// }
|
|
||||||
return; // Stop execution
|
|
||||||
case "error": {
|
|
||||||
// Use 'error'
|
|
||||||
const errorData = searchResult.data as LavalinkErrorData; // Cast to error structure
|
|
||||||
// Fix: Add explicit check for errorData
|
|
||||||
if (errorData) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to load track/playlist: ${errorData.message || "Unknown reason"} (Severity: ${errorData.severity || "Unknown"}, Identifier: ${identifier})`,
|
|
||||||
);
|
|
||||||
await _interaction.editReply(
|
|
||||||
`Failed to load track/playlist. Reason: ${errorData.message || "Unknown error"}`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
`Failed to load track/playlist: Unknown error (Identifier: ${identifier})`,
|
|
||||||
);
|
|
||||||
await _interaction.editReply(`Failed to load track/playlist. Unknown error.`);
|
|
||||||
}
|
|
||||||
return; // Stop execution
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Use exhaustive check pattern (will error if a case is missed)
|
|
||||||
const _exhaustiveCheck: never = searchResult.loadType;
|
|
||||||
logger.error(`Unknown loadType received: ${searchResult.loadType}`);
|
|
||||||
await _interaction.editReply("Received an unknown response type from the music server.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add tracks to the player's queue (ensure player exists)
|
|
||||||
if (!player) {
|
|
||||||
// This case should ideally not happen if join logic is correct, but added as safeguard
|
|
||||||
throw new Error("Player is not defined after processing search results.");
|
|
||||||
}
|
|
||||||
player.queue.push(...tracksToAdd);
|
|
||||||
|
|
||||||
// Send confirmation embed
|
|
||||||
await _interaction.editReply({ embeds: [responseEmbed] });
|
|
||||||
|
|
||||||
// 7. Start playback if not already playing
|
|
||||||
await playNext(player, _interaction);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Catch errors during the process
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(
|
|
||||||
`Error in play command for query "${query}" in guild ${_interaction.guildId}: ${errorMsg}`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
// Use editReply as _interaction is deferred
|
|
||||||
await _interaction
|
|
||||||
.editReply("An unexpected error occurred while trying to play the music.")
|
|
||||||
.catch((e: unknown) => {
|
|
||||||
const replyErrorMsg = e instanceof Error ? e.message : String(e);
|
|
||||||
logger.error(`Failed to send error reply for play command: ${replyErrorMsg}`);
|
|
||||||
});
|
|
||||||
// Optional: Attempt to leave VC on critical error?
|
|
||||||
// if (shoukaku.players.has(_interaction.guildId)) {
|
|
||||||
// await shoukaku.leaveVoiceChannel(_interaction.guildId).catch(() => {});
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
45
src/commands/skip.rs
Normal file
45
src/commands/skip.rs
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// src/commands/skip.rs
|
||||||
|
|
||||||
|
use super::{CommandHandler, SlashCommand};
|
||||||
|
use anyhow::{Context as _, Result};
|
||||||
|
use lavalink_rs::LavalinkClient;
|
||||||
|
use serenity::{
|
||||||
|
builder::{CreateApplicationCommandOption, EditInteractionResponse},
|
||||||
|
client::Context,
|
||||||
|
model::prelude::interaction::application_command::ApplicationCommandInteraction,
|
||||||
|
};
|
||||||
|
use tracing::instrument;
|
||||||
|
use futures::future::BoxFuture;
|
||||||
|
|
||||||
|
inventory::submit! {
|
||||||
|
SlashCommand {
|
||||||
|
name: "skip",
|
||||||
|
description: "Skips the currently playing song.",
|
||||||
|
options: || Vec::new(), // No options
|
||||||
|
handler: handle_skip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip(ctx, interaction, lavalink))]
|
||||||
|
fn handle_skip<'a>(
|
||||||
|
ctx: &'a Context,
|
||||||
|
interaction: &'a ApplicationCommandInteraction,
|
||||||
|
lavalink: &'a LavalinkClient,
|
||||||
|
) -> BoxFuture<'a, Result<()>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let guild_id = interaction.guild_id.context("Command must be used in a guild")?;
|
||||||
|
|
||||||
|
// Get the player for the guild
|
||||||
|
let player = lavalink.get_player(guild_id)
|
||||||
|
.context("Bot is not connected to a voice channel in this guild.")?;
|
||||||
|
|
||||||
|
// Stop the current track (Lavalink plays next if available)
|
||||||
|
player.stop().await?;
|
||||||
|
|
||||||
|
interaction.edit_original_response(&ctx.http,
|
||||||
|
EditInteractionResponse::new().content("Skipped track.")
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import { Events, Interaction } from "discord.js";
|
|
||||||
import { BotClient } from "../types/botClient.js";
|
|
||||||
import logger from "../utils/logger.js";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: Events.InteractionCreate,
|
|
||||||
async execute(interaction: Interaction, client?: BotClient) {
|
|
||||||
if (!interaction.isChatInputCommand()) return;
|
|
||||||
|
|
||||||
if (!client) {
|
|
||||||
logger.error("Client not provided to interaction handler");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = client.commands.get(interaction.commandName);
|
|
||||||
|
|
||||||
if (!command) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: "Command not found!",
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await command.execute(interaction, client);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error executing command ${interaction.commandName}:`, error);
|
|
||||||
if (interaction.isRepliable()) {
|
|
||||||
await interaction.reply({
|
|
||||||
content: "There was an error while executing this command!",
|
|
||||||
ephemeral: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import { Events, ActivityType, Client } from "discord.js"; // Import base Client type
|
|
||||||
import logger from "../utils/logger.js"; // Use default import
|
|
||||||
import { initializeShoukaku } from "../structures/ShoukakuEvents.js"; // Import the correct setup function
|
|
||||||
import { BotClient } from "../index.js"; // Import BotClient type
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Use export default
|
|
||||||
name: Events.ClientReady,
|
|
||||||
once: true, // This event should only run once
|
|
||||||
async execute(_client: BotClient) {
|
|
||||||
// Use BotClient type
|
|
||||||
// Ensure _client.user is available
|
|
||||||
if (!_client.user) {
|
|
||||||
logger.error("Client user is not available on ready event.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.info(`Ready! Logged in as ${_client.user.tag}`);
|
|
||||||
|
|
||||||
// Initialize the Shoukaku instance and attach listeners
|
|
||||||
try {
|
|
||||||
// Assign the initialized Shoukaku instance to _client.shoukaku
|
|
||||||
_client.shoukaku = initializeShoukaku(_client);
|
|
||||||
logger.info("Shoukaku instance initialized successfully"); // Log message adjusted slightly
|
|
||||||
} catch (error: unknown) {
|
|
||||||
// Type caught error
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(`Failed to initialize Shoukaku: ${errorMsg}`);
|
|
||||||
// Depending on the severity, you might want to exit or handle this differently
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set activity status
|
|
||||||
_client.user.setActivity("Music | /play", { type: ActivityType.Listening });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
import { Events, VoiceState, ChannelType } from "discord.js"; // Added ChannelType
|
|
||||||
import logger from "../utils/logger.js";
|
|
||||||
import { BotClient } from "../index.js"; // Assuming BotClient is exported from index
|
|
||||||
|
|
||||||
export default {
|
|
||||||
// Use export default for ES modules
|
|
||||||
name: Events.VoiceStateUpdate,
|
|
||||||
execute(oldState: VoiceState, newState: VoiceState, _client: BotClient) {
|
|
||||||
// Added types
|
|
||||||
// Shoukaku handles voice state updates internally via its connector.
|
|
||||||
// We don't need to manually pass the update like with Erela.js.
|
|
||||||
// The warning about Erela.js manager not being initialized can be ignored/removed.
|
|
||||||
|
|
||||||
// Custom logic for player cleanup based on voice state changes.
|
|
||||||
const shoukaku = _client.shoukaku; // Access Shoukaku instance
|
|
||||||
if (!shoukaku) {
|
|
||||||
// Shoukaku might not be initialized yet
|
|
||||||
logger.debug("Voice state update received, but Shoukaku is not ready yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const player = shoukaku.players.get(newState.guild.id); // Get player from Shoukaku players collection
|
|
||||||
if (!player) return; // No active player for this guild
|
|
||||||
|
|
||||||
// Get the connection associated with the player's guild
|
|
||||||
const connection = shoukaku.connections.get(player.guildId);
|
|
||||||
const currentChannelId = connection?.channelId; // Get channelId from connection
|
|
||||||
|
|
||||||
// Check if the bot was disconnected (newState has no channelId for the bot)
|
|
||||||
// Add null check for _client.user
|
|
||||||
if (
|
|
||||||
_client.user &&
|
|
||||||
newState.id === _client.user.id &&
|
|
||||||
!newState.channelId &&
|
|
||||||
oldState.channelId === currentChannelId
|
|
||||||
) {
|
|
||||||
logger.info(
|
|
||||||
`Bot was disconnected from voice channel ${oldState.channel?.name || oldState.channelId} in guild ${newState.guild.id}. Destroying player.`,
|
|
||||||
);
|
|
||||||
player.destroy(); // Use Shoukaku player's destroy method
|
|
||||||
return; // Exit early as the player is destroyed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the bot's channel is now empty (excluding the bot itself)
|
|
||||||
const channel = currentChannelId ? _client.channels.cache.get(currentChannelId) : undefined;
|
|
||||||
|
|
||||||
// Ensure the channel exists, is voice-based, and the update is relevant
|
|
||||||
if (
|
|
||||||
channel?.isVoiceBased() &&
|
|
||||||
(newState.channelId === currentChannelId || oldState.channelId === currentChannelId)
|
|
||||||
) {
|
|
||||||
// Fetch members again to ensure freshness after the update
|
|
||||||
const members = channel.members; // Safe to access members now
|
|
||||||
// Add null check for _client.user
|
|
||||||
if (_client.user && members.size === 1 && members.has(_client.user.id)) {
|
|
||||||
logger.info(
|
|
||||||
`Voice channel ${channel.name} (${currentChannelId}) in guild ${newState.guild.id} is now empty (only bot left). Destroying player.`,
|
|
||||||
); // Safe to access name
|
|
||||||
// Optional: Add a timeout before destroying
|
|
||||||
// setTimeout(() => {
|
|
||||||
// const currentChannel = _client.channels.cache.get(player.voiceChannel);
|
|
||||||
// const currentMembers = currentChannel?.members;
|
|
||||||
// if (currentMembers && currentMembers.size === 1 && currentMembers.has(_client.user.id)) {
|
|
||||||
// logger.info(`Timeout finished: Destroying player in empty channel ${channel.name}.`);
|
|
||||||
// player.destroy();
|
|
||||||
// } else {
|
|
||||||
// logger.info(`Timeout finished: Channel ${channel.name} is no longer empty. Player not destroyed.`);
|
|
||||||
// }
|
|
||||||
// }, 60000); // e.g., 1 minute timeout
|
|
||||||
player.destroy(); // Destroy immediately for now
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
199
src/handler.rs
Normal file
199
src/handler.rs
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
// src/handler.rs
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
state::BotState,
|
||||||
|
commands::SlashCommand, // Import the command definition struct
|
||||||
|
utils::get_voice_state,
|
||||||
|
};
|
||||||
|
use serenity::{
|
||||||
|
async_trait,
|
||||||
|
builder::{
|
||||||
|
CreateApplicationCommand, // Need this for registration
|
||||||
|
CreateInteractionResponse, CreateInteractionResponseData,
|
||||||
|
EditInteractionResponse
|
||||||
|
},
|
||||||
|
client::{Context, EventHandler},
|
||||||
|
gateway::ActivityData,
|
||||||
|
model::{
|
||||||
|
gateway::{Ready, Activity},
|
||||||
|
id::{GuildId, ChannelId},
|
||||||
|
prelude::interaction::{Interaction, application_command::ApplicationCommandInteraction},
|
||||||
|
voice::VoiceState,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use lavalink_rs::LavalinkClient;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tokio::time::Duration;
|
||||||
|
use tracing::{error, info, instrument, warn};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
// --- Event Handler ---
|
||||||
|
pub struct Handler {
|
||||||
|
pub state: Arc<Mutex<BotState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods (check_and_handle_auto_leave remains the same as previous version)
|
||||||
|
impl Handler {
|
||||||
|
#[instrument(skip(self, ctx, lavalink))]
|
||||||
|
async fn check_and_handle_auto_leave(
|
||||||
|
&self,
|
||||||
|
ctx: &Context,
|
||||||
|
guild_id: GuildId,
|
||||||
|
bot_channel_id: ChannelId,
|
||||||
|
lavalink: &LavalinkClient,
|
||||||
|
) {
|
||||||
|
let members_in_channel = ctx.cache.guild(guild_id)
|
||||||
|
.await
|
||||||
|
.map(|guild| {
|
||||||
|
guild.voice_states.iter()
|
||||||
|
.filter(|(_, vs)| vs.channel_id == Some(bot_channel_id))
|
||||||
|
.count()
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some(1) = members_in_channel else { return; };
|
||||||
|
|
||||||
|
info!("Bot potentially alone in channel {}, waiting...", bot_channel_id);
|
||||||
|
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||||
|
|
||||||
|
let Some(player_after_delay) = lavalink.get_player(guild_id) else { return; };
|
||||||
|
if player_after_delay.connected_channel_id != Some(bot_channel_id) { return; };
|
||||||
|
|
||||||
|
let members_after_delay = ctx.cache.guild(guild_id)
|
||||||
|
.await
|
||||||
|
.map(|guild| {
|
||||||
|
guild.voice_states.iter()
|
||||||
|
.filter(|(_, vs)| vs.channel_id == Some(bot_channel_id))
|
||||||
|
.count()
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some(1) = members_after_delay else { return; };
|
||||||
|
|
||||||
|
info!("Bot confirmed alone in channel {}, leaving.", bot_channel_id);
|
||||||
|
if let Err(e) = lavalink.destroy_session(guild_id).await {
|
||||||
|
error!("Failed auto-leave {}: {}", bot_channel_id, e);
|
||||||
|
} else {
|
||||||
|
info!("Successfully auto-left {}", bot_channel_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventHandler for Handler {
|
||||||
|
/// Called when the bot is ready. Registers slash commands.
|
||||||
|
#[instrument(skip(self, ctx, ready))]
|
||||||
|
async fn ready(&self, ctx: Context, ready: Ready) {
|
||||||
|
info!("{} is connected!", ready.user.name);
|
||||||
|
|
||||||
|
// --- Command Registration using inventory ---
|
||||||
|
info!("Registering global slash commands...");
|
||||||
|
let command_build_results = serenity::model::application::command::Command::set_global_application_commands(
|
||||||
|
&ctx.http,
|
||||||
|
|commands_builder| {
|
||||||
|
for cmd in inventory::iter::<SlashCommand> {
|
||||||
|
commands_builder.create_application_command(|command| {
|
||||||
|
command.name(cmd.name).description(cmd.description);
|
||||||
|
// Build options using the provided function
|
||||||
|
let options = (cmd.options)();
|
||||||
|
for opt in options {
|
||||||
|
command.add_option(opt); // Use add_option
|
||||||
|
}
|
||||||
|
command // Return the command builder
|
||||||
|
});
|
||||||
|
}
|
||||||
|
commands_builder // Return the main builder
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match command_build_results {
|
||||||
|
Ok(registered_commands) => info!("Successfully registered {} global commands.", registered_commands.len()),
|
||||||
|
Err(err) => error!("Failed to register global commands: {:?}", err),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set_activity(ActivityData::custom("🎶 Music Time")).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Called for interactions. Dispatches commands.
|
||||||
|
#[instrument(skip(self, ctx))]
|
||||||
|
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
||||||
|
let Interaction::ApplicationCommand(command) = interaction else { return; };
|
||||||
|
|
||||||
|
info!("Received command: /{}", command.data.name);
|
||||||
|
|
||||||
|
if let Err(e) = command.create_response(&ctx.http,
|
||||||
|
CreateInteractionResponse::Defer(
|
||||||
|
CreateInteractionResponseData::new().ephemeral(false)
|
||||||
|
)
|
||||||
|
).await {
|
||||||
|
error!("Failed to defer interaction response: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(guild_id) = command.guild_id else {
|
||||||
|
warn!("Guild command '/{}' used outside of a guild.", command.data.name);
|
||||||
|
let _ = command.edit_original_response(&ctx.http,
|
||||||
|
EditInteractionResponse::new().content("This command only works in servers.")
|
||||||
|
).await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Command Dispatch using inventory ---
|
||||||
|
let command_name = command.data.name.as_str();
|
||||||
|
let mut command_found = false;
|
||||||
|
let mut command_result: Option<anyhow::Result<()>> = None;
|
||||||
|
|
||||||
|
{ // Scope for state lock
|
||||||
|
let state_guard = self.state.lock().await;
|
||||||
|
let lavalink = &state_guard.lavalink;
|
||||||
|
|
||||||
|
for cmd_def in inventory::iter::<SlashCommand> {
|
||||||
|
if cmd_def.name == command_name {
|
||||||
|
info!("Dispatching command: {}", cmd_def.name);
|
||||||
|
command_found = true;
|
||||||
|
// Call the registered handler function
|
||||||
|
command_result = Some((cmd_def.handler)(&ctx, &command, lavalink).await);
|
||||||
|
break; // Found and handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} // state_guard lock released
|
||||||
|
|
||||||
|
// --- Handle Dispatch Result ---
|
||||||
|
if !command_found {
|
||||||
|
error!("Handler not found for received command: {}", command_name);
|
||||||
|
command_result = Some(Err(anyhow::anyhow!("Unknown command '/{}'", command_name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the result (error logging/reporting)
|
||||||
|
if let Some(Err(e)) = command_result {
|
||||||
|
error!("Error handling command '/{}': {:?}", command_name, e);
|
||||||
|
if let Err(edit_err) = command.edit_original_response(&ctx.http,
|
||||||
|
EditInteractionResponse::new().content(format!("An error occurred: {}", e))
|
||||||
|
).await {
|
||||||
|
error!("Failed to send error response for '/{}': {}", command_name, edit_err);
|
||||||
|
}
|
||||||
|
} else if command_found {
|
||||||
|
info!("Successfully handled command '/{}'", command_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called for voice state changes. Handles Lavalink updates and auto-leave.
|
||||||
|
#[instrument(skip(self, ctx))]
|
||||||
|
async fn voice_state_update(&self, ctx: Context, _old: Option<VoiceState>, new: VoiceState) {
|
||||||
|
let state_guard = self.state.lock().await;
|
||||||
|
let lavalink = &state_guard.lavalink;
|
||||||
|
|
||||||
|
// Always forward to lavalink-rs
|
||||||
|
lavalink.handle_voice_state_update(&ctx.cache, &new).await;
|
||||||
|
|
||||||
|
// Auto-Leave Logic (simplified)
|
||||||
|
let Some(guild_id) = new.guild_id else { return; };
|
||||||
|
let Some(player) = lavalink.get_player(guild_id) else { return; };
|
||||||
|
let Some(bot_channel_id) = player.connected_channel_id else { return; };
|
||||||
|
|
||||||
|
// Drop the lock before calling the helper which might sleep
|
||||||
|
drop(state_guard);
|
||||||
|
self.check_and_handle_auto_leave(&ctx, guild_id, bot_channel_id, lavalink).await;
|
||||||
|
}
|
||||||
|
} // End impl EventHandler
|
||||||
200
src/index.ts
200
src/index.ts
@ -1,200 +0,0 @@
|
|||||||
import dotenv from "dotenv";
|
|
||||||
import {
|
|
||||||
Client,
|
|
||||||
GatewayIntentBits,
|
|
||||||
Collection,
|
|
||||||
Events,
|
|
||||||
BaseInteraction,
|
|
||||||
SlashCommandBuilder,
|
|
||||||
} from "discord.js";
|
|
||||||
import { Shoukaku, Connectors, NodeOption, ShoukakuOptions } from "shoukaku";
|
|
||||||
import logger from "./utils/logger.js"; // Add .js extension
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { fileURLToPath } from 'url'; // Needed for __dirname in ES Modules
|
|
||||||
|
|
||||||
// Get __dirname equivalent in ES Modules
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
// Define Command structure
|
|
||||||
interface Command {
|
|
||||||
data: Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">;
|
|
||||||
execute: (_interaction: BaseInteraction, _client: BotClient) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define Event structure
|
|
||||||
interface BotEvent {
|
|
||||||
name: string;
|
|
||||||
once?: boolean;
|
|
||||||
execute: (..._args: any[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extend the discord.js Client class to include custom properties
|
|
||||||
export interface BotClient extends Client {
|
|
||||||
commands: Collection<string, Command>;
|
|
||||||
shoukaku: Shoukaku;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Setup ---
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
// Validate essential environment variables
|
|
||||||
if (!process.env.DISCORD_TOKEN) {
|
|
||||||
logger.error("DISCORD_TOKEN is missing in the .env file!");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
if (!process.env.LAVALINK_HOST || !process.env.LAVALINK_PORT || !process.env.LAVALINK_PASSWORD) {
|
|
||||||
logger.warn(
|
|
||||||
"Lavalink connection details (HOST, PORT, PASSWORD) are missing or incomplete in .env. Music functionality will be limited.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new Discord _client instance with necessary intents
|
|
||||||
const _client = new Client({
|
|
||||||
intents: [
|
|
||||||
GatewayIntentBits.Guilds,
|
|
||||||
GatewayIntentBits.GuildVoiceStates,
|
|
||||||
GatewayIntentBits.GuildMessages,
|
|
||||||
GatewayIntentBits.MessageContent,
|
|
||||||
],
|
|
||||||
}) as BotClient;
|
|
||||||
|
|
||||||
// Define Shoukaku nodes
|
|
||||||
const Nodes: NodeOption[] = [
|
|
||||||
{
|
|
||||||
name: process.env.LAVALINK_NAME || "lavalink-node-1",
|
|
||||||
url: `${process.env.LAVALINK_HOST || "localhost"}:${process.env.LAVALINK_PORT || 2333}`,
|
|
||||||
auth: process.env.LAVALINK_PASSWORD || "youshallnotpass",
|
|
||||||
secure: process.env.LAVALINK_SECURE === "true",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Shoukaku _options
|
|
||||||
const shoukakuOptions: ShoukakuOptions = {
|
|
||||||
moveOnDisconnect: false,
|
|
||||||
resume: true,
|
|
||||||
reconnectTries: 3,
|
|
||||||
reconnectInterval: 5000,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize Shoukaku
|
|
||||||
_client.shoukaku = new Shoukaku(new Connectors.DiscordJS(_client), Nodes, shoukakuOptions);
|
|
||||||
|
|
||||||
// Show the actual Lavalink connection details (without exposing the actual password)
|
|
||||||
logger.info(
|
|
||||||
`Lavalink connection configured to: ${process.env.LAVALINK_HOST || "localhost"}:${process.env.LAVALINK_PORT || 2333} (Password: ${process.env.LAVALINK_PASSWORD ? "[SET]" : "[NOT SET]"})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Collections for commands
|
|
||||||
_client.commands = new Collection<string, Command>();
|
|
||||||
|
|
||||||
// --- Command Loading ---
|
|
||||||
const commandsPath = path.join(__dirname, "commands");
|
|
||||||
// Read .js files instead of .ts after compilation
|
|
||||||
const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith(".js"));
|
|
||||||
|
|
||||||
const loadCommands = async () => {
|
|
||||||
for (const file of commandFiles) {
|
|
||||||
const filePath = path.join(commandsPath, file);
|
|
||||||
try {
|
|
||||||
// Use dynamic import with file:// protocol for ES Modules
|
|
||||||
const fileUrl = new URL(`file://${filePath}`).href;
|
|
||||||
const commandModule = await import(fileUrl);
|
|
||||||
const command: Command = commandModule.default || commandModule;
|
|
||||||
|
|
||||||
if (command && typeof command === "object" && "data" in command && "execute" in command) {
|
|
||||||
_client.commands.set(command.data.name, command);
|
|
||||||
logger.info(`Loaded command: ${command.data.name}`);
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property or is not structured correctly.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(`Error loading command at ${filePath}: ${errorMessage}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Event Handling ---
|
|
||||||
const eventsPath = path.join(__dirname, "events");
|
|
||||||
// Read .js files instead of .ts after compilation
|
|
||||||
const eventFiles = fs.readdirSync(eventsPath).filter((file: string) => file.endsWith(".js"));
|
|
||||||
|
|
||||||
const loadEvents = async () => {
|
|
||||||
for (const file of eventFiles) {
|
|
||||||
const filePath = path.join(eventsPath, file);
|
|
||||||
try {
|
|
||||||
// Use dynamic import with file:// protocol for ES Modules
|
|
||||||
const fileUrl = new URL(`file://${filePath}`).href;
|
|
||||||
const eventModule = await import(fileUrl);
|
|
||||||
const event: BotEvent = eventModule.default || eventModule;
|
|
||||||
|
|
||||||
if (event && typeof event === "object" && "name" in event && "execute" in event) {
|
|
||||||
if (event.once) {
|
|
||||||
_client.once(event.name, (..._args: any[]) => event.execute(..._args, _client));
|
|
||||||
logger.info(`Loaded event ${event.name} (once)`);
|
|
||||||
} else {
|
|
||||||
_client.on(event.name, (..._args: any[]) => event.execute(..._args, _client));
|
|
||||||
logger.info(`Loaded event ${event.name}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
`[WARNING] The event at ${filePath} is missing a required "name" or "execute" property or is not structured correctly.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(`Error loading event at ${filePath}: ${errorMessage}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Shoukaku Event Handling ---
|
|
||||||
_client.shoukaku.on("ready", (name: string) =>
|
|
||||||
logger.info(`Lavalink Node: ${name} is now connected`),
|
|
||||||
);
|
|
||||||
_client.shoukaku.on("error", (name: string, error: Error) =>
|
|
||||||
logger.error(`Lavalink Node: ${name} emitted an error: ${error.message}`),
|
|
||||||
);
|
|
||||||
_client.shoukaku.on("close", (name: string, code: number, reason: string | undefined) =>
|
|
||||||
logger.warn(`Lavalink Node: ${name} closed with code ${code}. Reason: ${reason || "No reason"}`),
|
|
||||||
);
|
|
||||||
_client.shoukaku.on("disconnect", (name: string, count: number) => {
|
|
||||||
logger.warn(
|
|
||||||
`Lavalink Node: ${name} disconnected. ${count} players were disconnected from this node.`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Main Execution ---
|
|
||||||
async function main() {
|
|
||||||
await loadCommands();
|
|
||||||
await loadEvents();
|
|
||||||
|
|
||||||
// Log in to Discord with your _client's token
|
|
||||||
try {
|
|
||||||
await _client.login(process.env.DISCORD_TOKEN);
|
|
||||||
logger.info("Successfully logged in to Discord.");
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(`Failed to log in: ${errorMessage}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(`Error during bot initialization: ${errorMessage}`, error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Basic error handling
|
|
||||||
process.on("unhandledRejection", (reason: unknown, promise: Promise<any>) => {
|
|
||||||
const reasonMessage = reason instanceof Error ? reason.message : String(reason);
|
|
||||||
logger.error("Unhandled promise rejection:", { reason: reasonMessage, promise });
|
|
||||||
});
|
|
||||||
process.on("uncaughtException", (error: Error, origin: NodeJS.UncaughtExceptionOrigin) => {
|
|
||||||
logger.error(`Uncaught exception: ${error.message}`, { error, origin });
|
|
||||||
});
|
|
||||||
85
src/lavalink_handler.rs
Normal file
85
src/lavalink_handler.rs
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
// src/lavalink_handler.rs
|
||||||
|
|
||||||
|
use serenity::http::Http; // Import Http for sending messages
|
||||||
|
use lavalink_rs::gateway::{NodeEvent, LavalinkEventHandler as LavalinkEventHandlerRs};
|
||||||
|
use tracing::{error, info, instrument};
|
||||||
|
use async_trait::async_trait; // Needed to implement async trait methods
|
||||||
|
use std::sync::Arc; // Needed for Arc<Http>
|
||||||
|
|
||||||
|
|
||||||
|
// --- lavalink-rs Event Handler ---
|
||||||
|
// This handler receives events directly from the connected Lavalink server.
|
||||||
|
// It allows reacting to playback events like track start/end.
|
||||||
|
pub struct MyLavalinkEventHandlerRs {
|
||||||
|
pub http: Arc<Http>, // Hold the Http client to send messages to Discord
|
||||||
|
// Add state if needed, e.g., channel IDs for announcements per guild.
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LavalinkEventHandlerRs for MyLavalinkEventHandlerRs {
|
||||||
|
// The main handler function for all Lavalink NodeEvents.
|
||||||
|
#[instrument(skip(self))] // Adds tracing to the handler
|
||||||
|
async fn handle(&self, event: NodeEvent) {
|
||||||
|
// You can uncomment this for verbose logging of all events:
|
||||||
|
// info!("Received LavaLink event: {:?}", event);
|
||||||
|
|
||||||
|
match event {
|
||||||
|
// Handle specific event types
|
||||||
|
NodeEvent::TrackStart { guild_id, track, .. } => {
|
||||||
|
info!(
|
||||||
|
"Track started on guild {}: {}",
|
||||||
|
guild_id,
|
||||||
|
track.info.title
|
||||||
|
);
|
||||||
|
// Example: send a message announcing the track.
|
||||||
|
// This requires knowing which channel to send it to, often
|
||||||
|
// stored per guild in the BotState's announcement_channels.
|
||||||
|
// Accessing BotState would require passing it here or using a global static.
|
||||||
|
// let channel_id = { /* logic to get channel_id from guild_id using self.http or shared state */ };
|
||||||
|
// if let Some(channel_id) = channel_id {
|
||||||
|
// if let Err(e) = ChannelId(channel_id).say(&self.http,
|
||||||
|
// format!("Now playing: **{}** by **{}**", track.info.title, track.info.author)
|
||||||
|
// ).await {
|
||||||
|
// error!("Failed to send track start message: {}", e);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
NodeEvent::TrackEnd { guild_id, track, reason, .. } => {
|
||||||
|
info!(
|
||||||
|
"Track ended on guild {}: {}, reason: {:?}",
|
||||||
|
guild_id,
|
||||||
|
track.info.title,
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
// Logic for track end, e.g., cleaning up state if not using Lavalink queues
|
||||||
|
}
|
||||||
|
NodeEvent::TrackException { guild_id, track, error, .. } => {
|
||||||
|
error!(
|
||||||
|
"Track exception on guild {}: {}, error: {}",
|
||||||
|
guild_id,
|
||||||
|
track.info.title,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
// Notify the channel about the exception
|
||||||
|
}
|
||||||
|
NodeEvent::TrackStuck { guild_id, track, threshold_ms, .. } => {
|
||||||
|
error!(
|
||||||
|
"Track stuck on guild {}: {}, threshold: {}ms",
|
||||||
|
guild_id,
|
||||||
|
track.info.title,
|
||||||
|
threshold_ms
|
||||||
|
);
|
||||||
|
// Notify the channel about the stuck track
|
||||||
|
}
|
||||||
|
NodeEvent::WebSocketOpen { node, .. } => {
|
||||||
|
info!("Lavalink WebSocket opened: {}", node);
|
||||||
|
}
|
||||||
|
NodeEvent::WebSocketClosed { node, code, reason, .. } => {
|
||||||
|
error!("Lavalink WebSocket closed: {}, Code: {:?}, Reason: {}", node, code, reason);
|
||||||
|
}
|
||||||
|
// Ignore these common events to avoid log spam
|
||||||
|
NodeEvent::Ready { .. } | NodeEvent::Stats { .. } => { /* Ignore */ }
|
||||||
|
NodeEvent::PlayerUpdate { .. } => { /* Ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/main.rs
Normal file
123
src/main.rs
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
// src/main.rs
|
||||||
|
|
||||||
|
// Declare the modules used in this crate
|
||||||
|
mod handler;
|
||||||
|
mod state;
|
||||||
|
mod commands;
|
||||||
|
mod lavalink_handler;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
// Import necessary types from our modules
|
||||||
|
use handler::Handler;
|
||||||
|
use state::BotState;
|
||||||
|
use lavalink_handler::MyLavalinkEventHandlerRs;
|
||||||
|
|
||||||
|
// Import necessary types from external crates
|
||||||
|
use serenity::client::Client;
|
||||||
|
use serenity::prelude::GatewayIntents; // Needed to subscribe to events
|
||||||
|
use lavalink_rs::LavalinkClient;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{error, info, instrument};
|
||||||
|
use std::sync::Arc; // For shared ownership
|
||||||
|
use std::env; // To read environment variables
|
||||||
|
|
||||||
|
// The entry point of the application
|
||||||
|
#[tokio::main]
|
||||||
|
#[instrument] // Adds tracing to the main function
|
||||||
|
async fn main() {
|
||||||
|
// Initialize tracing for logging output
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_max_level(tracing::Level::INFO) // Set logging level
|
||||||
|
.init();
|
||||||
|
|
||||||
|
// Load environment variables from .env file
|
||||||
|
dotenv::dotenv().ok();
|
||||||
|
|
||||||
|
// Get credentials from environment variables
|
||||||
|
let token = env::var("DISCORD_TOKEN").expect("DISCORD_TOKEN not set");
|
||||||
|
let lavalink_host = env::var("LAVALINK_HOST")
|
||||||
|
.expect("LAVALINK_HOST not set");
|
||||||
|
let lavalink_port = env::var("LAVALINK_PORT")
|
||||||
|
.expect("LAVALINK_PORT not set")
|
||||||
|
.parse::<u16>()
|
||||||
|
.expect("Invalid LAVALINK_PORT");
|
||||||
|
let lavalink_password = env::var("LAVALINK_PASSWORD")
|
||||||
|
.expect("LAVALINK_PASSWORD not set");
|
||||||
|
|
||||||
|
// Define the gateway intents needed for the bot
|
||||||
|
// GUILDS: To receive guild information for commands and cache
|
||||||
|
// GUILD_VOICE_STATES: Crucial for receiving voice state updates
|
||||||
|
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES;
|
||||||
|
|
||||||
|
// Build the Serenity client. Need the await here because of the async builder.
|
||||||
|
let mut serenity_client = Client::builder(&token, intents)
|
||||||
|
.await
|
||||||
|
.expect("Error creating serenity client");
|
||||||
|
|
||||||
|
// Clone the Arc<Http> client from Serenity. We need this to be able to send
|
||||||
|
// HTTP requests (like sending messages) from places that don't have the full Context,
|
||||||
|
// such as the Lavalink event handler.
|
||||||
|
let http = serenity_client.cache_and_http.http.clone();
|
||||||
|
|
||||||
|
// Get the bot's user ID from the cache. This is needed to initialize the Lavalink client.
|
||||||
|
let bot_user_id = serenity_client
|
||||||
|
.cache_and_http
|
||||||
|
.cache
|
||||||
|
.current_user_id()
|
||||||
|
.await
|
||||||
|
.expect("Failed to get bot user ID from cache");
|
||||||
|
|
||||||
|
// Create the Lavalink client builder
|
||||||
|
let mut lavalink_client_builder = LavalinkClient::builder(bot_user_id);
|
||||||
|
|
||||||
|
// Set Lavalink node details
|
||||||
|
lavalink_client_builder
|
||||||
|
.set_host(lavalink_host)
|
||||||
|
.set_port(lavalink_port)
|
||||||
|
.set_password(lavalink_password);
|
||||||
|
|
||||||
|
// Create and set the custom Lavalink event handler, passing the http client
|
||||||
|
let lavalink_event_handler = Arc::new(MyLavalinkEventHandlerRs {
|
||||||
|
http: http.clone() // Clone Http again for the lavalink event handler
|
||||||
|
});
|
||||||
|
lavalink_client_builder.set_event_handler(lavalink_event_handler);
|
||||||
|
|
||||||
|
// Build the Lavalink client
|
||||||
|
let lavalink_client = lavalink_client_builder.build();
|
||||||
|
|
||||||
|
// Create and store the shared state (BotState)
|
||||||
|
let bot_state = Arc::new(Mutex::new(BotState {
|
||||||
|
lavalink: lavalink_client.clone(), // Clone lavalink client for the handler struct
|
||||||
|
http: http, // Store the Http client clone in the state
|
||||||
|
// announcement_channels: Mutex::new(HashMap::new()), // Optional: for announcements
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Set the Serenity event handler, passing the shared state
|
||||||
|
serenity_client.event_handler(Handler { state: bot_state });
|
||||||
|
|
||||||
|
// Spawn the lavalink-rs client's main run task. This task will manage the
|
||||||
|
// WebSocket connection to the Lavalink server and process its events.
|
||||||
|
let lavalink_task = tokio::spawn(async move {
|
||||||
|
info!("Starting lavalink-rs client task...");
|
||||||
|
if let Err(why) = lavalink_client.run().await {
|
||||||
|
error!("Lavalink client task error: {:?}", why);
|
||||||
|
}
|
||||||
|
info!("Lavalink client task stopped.");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Start the Serenity client gateway. This connects the bot to Discord
|
||||||
|
// and begins receiving events. This call is blocking until the client stops.
|
||||||
|
info!("Starting serenity client gateway...");
|
||||||
|
if let Err(why) = serenity_client.start().await {
|
||||||
|
error!("Serenity client gateway error: {:?}", why);
|
||||||
|
}
|
||||||
|
info!("Serenity client gateway stopped.");
|
||||||
|
|
||||||
|
|
||||||
|
// Wait for the lavalink task to finish.
|
||||||
|
// In a real bot, this task should ideally never finish unless there's a critical error.
|
||||||
|
let _ = lavalink_task.await;
|
||||||
|
|
||||||
|
info!("Bot shut down.");
|
||||||
|
}
|
||||||
41
src/mod.rs
Normal file
41
src/mod.rs
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// src/commands/mod.rs
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use lavalink_rs::LavalinkClient;
|
||||||
|
use serenity::{
|
||||||
|
builder::CreateApplicationCommandOption, // Correct import path
|
||||||
|
client::Context,
|
||||||
|
model::prelude::interaction::application_command::ApplicationCommandInteraction,
|
||||||
|
};
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use futures::future::BoxFuture; // Use BoxFuture for convenience
|
||||||
|
|
||||||
|
// --- Command Definition ---
|
||||||
|
|
||||||
|
// Type alias for the async command handler function signature
|
||||||
|
pub type CommandHandler = for<'a> fn(
|
||||||
|
&'a Context,
|
||||||
|
&'a ApplicationCommandInteraction,
|
||||||
|
&'a LavalinkClient,
|
||||||
|
) -> BoxFuture<'a, Result<()>>;
|
||||||
|
|
||||||
|
// Structure to hold all information needed to register and execute a command
|
||||||
|
pub struct SlashCommand {
|
||||||
|
pub name: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
// Function to build command options (lazily evaluated)
|
||||||
|
pub options: fn() -> Vec<CreateApplicationCommandOption>,
|
||||||
|
// The async handler function
|
||||||
|
pub handler: CommandHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell `inventory` to collect all `SlashCommand` structs submitted globally.
|
||||||
|
inventory::collect!(SlashCommand);
|
||||||
|
|
||||||
|
// --- Submodule Declarations ---
|
||||||
|
// Declare all files in the commands/ directory as submodules
|
||||||
|
pub mod join;
|
||||||
|
pub mod leave;
|
||||||
|
pub mod play;
|
||||||
|
pub mod skip;
|
||||||
17
src/state.rs
Normal file
17
src/state.rs
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// src/state.rs
|
||||||
|
|
||||||
|
use lavalink_rs::LavalinkClient;
|
||||||
|
use serenity::{http::Http, model::id::{GuildId, ChannelId}};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use std::{sync::Arc, collections::HashMap};
|
||||||
|
|
||||||
|
// BotState holds shared data accessible by different parts of the bot,
|
||||||
|
// like the Lavalink client and the Serenity HTTP client.
|
||||||
|
pub struct BotState {
|
||||||
|
pub lavalink: LavalinkClient,
|
||||||
|
// Need Http client to send messages from areas that don't have Context,
|
||||||
|
// like the Lavalink event handler.
|
||||||
|
pub http: Arc<Http>,
|
||||||
|
// Optional: Store announcement channels per guild for track announcements.
|
||||||
|
// pub announcement_channels: Mutex<HashMap<GuildId, ChannelId>>,
|
||||||
|
}
|
||||||
@ -1,66 +0,0 @@
|
|||||||
import { Shoukaku, NodeOption, ShoukakuOptions, Player, Connectors } from 'shoukaku';
|
|
||||||
import logger from '../utils/logger.js';
|
|
||||||
import { BotClient } from '../index.js';
|
|
||||||
// Removed imports from play.ts for now as player listeners are removed
|
|
||||||
|
|
||||||
// Define Node options (replace with your actual Lavalink details from .env)
|
|
||||||
const nodes: NodeOption[] = [
|
|
||||||
{
|
|
||||||
name: process.env.LAVALINK_NAME || 'Lavalink-Node-1',
|
|
||||||
url: process.env.LAVALINK_URL || 'lavalink:2333', // Use service name for Docker Compose if applicable
|
|
||||||
auth: process.env.LAVALINK_AUTH || 'youshallnotpass',
|
|
||||||
secure: process.env.LAVALINK_SECURE === 'true' || false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Define Shoukaku options
|
|
||||||
const shoukakuOptions: ShoukakuOptions = {
|
|
||||||
moveOnDisconnect: false,
|
|
||||||
resume: false, // Resume doesn't work reliably across restarts/disconnects without session persistence
|
|
||||||
reconnectTries: 3,
|
|
||||||
reconnectInterval: 5, // In seconds
|
|
||||||
restTimeout: 15000, // In milliseconds
|
|
||||||
voiceConnectionTimeout: 15, // In seconds
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to initialize Shoukaku and attach listeners
|
|
||||||
export function initializeShoukaku(client: BotClient): Shoukaku {
|
|
||||||
if (!client) {
|
|
||||||
throw new Error("initializeShoukaku requires a client instance.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const shoukaku = new Shoukaku(new Connectors.DiscordJS(client), nodes, shoukakuOptions);
|
|
||||||
|
|
||||||
// --- Shoukaku Node Event Listeners ---
|
|
||||||
shoukaku.on('ready', (name, resumed) =>
|
|
||||||
logger.info(`Lavalink Node '${name}' ready. Resumed: ${resumed}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
shoukaku.on('error', (name, error) =>
|
|
||||||
logger.error(`Lavalink Node '${name}' error: ${error.message}`, error)
|
|
||||||
);
|
|
||||||
|
|
||||||
shoukaku.on('close', (name, code, reason) =>
|
|
||||||
logger.warn(`Lavalink Node '${name}' closed. Code: ${code}. Reason: ${reason || 'No reason'}`)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fix: Correct disconnect listener signature
|
|
||||||
shoukaku.on('disconnect', (name, count) => {
|
|
||||||
// count = count of players disconnected from the node
|
|
||||||
logger.warn(`Lavalink Node '${name}' disconnected. ${count} players disconnected.`);
|
|
||||||
// If players were not moved, you might want to attempt to reconnect them or clean them up.
|
|
||||||
});
|
|
||||||
|
|
||||||
shoukaku.on('debug', (name, info) => {
|
|
||||||
// Only log debug messages if not in production or if explicitly enabled
|
|
||||||
if (process.env.NODE_ENV !== 'production' || process.env.LAVALINK_DEBUG === 'true') {
|
|
||||||
logger.debug(`Lavalink Node '${name}' debug: ${info}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Shoukaku Player Event Listeners ---
|
|
||||||
// REMOVED - These need to be attached differently in Shoukaku v4 (e.g., when player is created)
|
|
||||||
|
|
||||||
logger.info("Shoukaku instance created and node event listeners attached.");
|
|
||||||
return shoukaku;
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { Client, Collection } from "discord.js";
|
|
||||||
import { Shoukaku } from "shoukaku";
|
|
||||||
|
|
||||||
export interface BotClient extends Client {
|
|
||||||
commands: Collection<string, any>;
|
|
||||||
shoukaku: Shoukaku;
|
|
||||||
}
|
|
||||||
25
src/utils.rs
Normal file
25
src/utils.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// src/utils.rs
|
||||||
|
|
||||||
|
use serenity::{
|
||||||
|
client::Context,
|
||||||
|
model::{
|
||||||
|
id::{GuildId, UserId},
|
||||||
|
voice::VoiceState,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use tracing::instrument;
|
||||||
|
|
||||||
|
|
||||||
|
// Helper function to find the voice state of a user in a specific guild
|
||||||
|
// using the cached data. Returns None if the user is not in a voice channel
|
||||||
|
// in that guild, or if the guild is not in the cache.
|
||||||
|
#[instrument(skip(ctx))]
|
||||||
|
pub async fn get_voice_state(
|
||||||
|
ctx: &Context,
|
||||||
|
guild_id: GuildId,
|
||||||
|
user_id: UserId
|
||||||
|
) -> Option<VoiceState> {
|
||||||
|
ctx.cache.guild(guild_id).await
|
||||||
|
// Get the guild from cache, then look up the user's voice state in that guild
|
||||||
|
.and_then(|guild| guild.voice_states.get(&user_id).cloned())
|
||||||
|
}
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import winston, { format, transports } from "winston"; // Use ES6 import
|
|
||||||
// No longer needed: import { TransformableInfo } from 'logform';
|
|
||||||
|
|
||||||
// Define the type for the log info object after timestamp is added
|
|
||||||
// We can simplify this for now or try to infer from winston later
|
|
||||||
// type TimestampedLogInfo = TransformableInfo & {
|
|
||||||
// timestamp: string;
|
|
||||||
// };
|
|
||||||
|
|
||||||
const logger = winston.createLogger({
|
|
||||||
level: process.env.LOG_LEVEL || "info", // Use LOG_LEVEL from env or default to 'info'
|
|
||||||
format: format.combine(
|
|
||||||
format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), // This adds the timestamp
|
|
||||||
format.printf((info: any) => {
|
|
||||||
// Use 'any' for now to bypass strict type checking here
|
|
||||||
// Ensure message exists, handle potential non-string messages if necessary
|
|
||||||
// The 'info' object structure depends on the preceding formatters
|
|
||||||
const timestamp = info.timestamp || new Date().toISOString(); // Fallback if timestamp isn't added
|
|
||||||
const level = (info.level || "info").toUpperCase();
|
|
||||||
const message =
|
|
||||||
typeof info.message === "string" ? info.message : JSON.stringify(info.message);
|
|
||||||
return `${timestamp} ${level}: ${message}`;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
transports: [
|
|
||||||
new transports.Console(),
|
|
||||||
// Optionally add file transport
|
|
||||||
// new transports.File({ filename: 'combined.log' }),
|
|
||||||
// new transports.File({ filename: 'error.log', level: 'error' }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
export default logger; // Use ES6 export default
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "./tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"rootDir": ".",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext"
|
|
||||||
},
|
|
||||||
"include": ["deploy-commands.ts"]
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2020",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"lib": ["ES2020"],
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"strict": true,
|
|
||||||
"noImplicitAny": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"declaration": true,
|
|
||||||
"sourceMap": true,
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules",
|
|
||||||
"dist"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Exit immediately if a command exits with a non-zero status.
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Define variables
|
|
||||||
PLUGIN_DIR="./plugins"
|
|
||||||
REPO_URL="https://maven.lavalink.dev/snapshots/dev/lavalink/youtube/youtube-plugin"
|
|
||||||
METADATA_URL="${REPO_URL}/maven-metadata.xml"
|
|
||||||
ARTIFACT_ID="youtube-plugin"
|
|
||||||
|
|
||||||
echo "Fetching latest snapshot version..."
|
|
||||||
|
|
||||||
# Fetch metadata and extract the latest snapshot version using grep and sed
|
|
||||||
# Use curl with -sS for silent operation but show errors
|
|
||||||
# Use grep to find the <latest> tag, then sed to extract the content
|
|
||||||
LATEST_VERSION=$(curl -sS "$METADATA_URL" | grep '<latest>' | sed -e 's/.*<latest>\(.*\)<\/latest>.*/\1/')
|
|
||||||
|
|
||||||
if [ -z "$LATEST_VERSION" ]; then
|
|
||||||
echo "Error: Could not determine the latest snapshot version."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Latest snapshot version: $LATEST_VERSION"
|
|
||||||
|
|
||||||
# Construct the JAR filename and download URL
|
|
||||||
JAR_FILENAME="${ARTIFACT_ID}-${LATEST_VERSION}.jar"
|
|
||||||
DOWNLOAD_URL="${REPO_URL}/${LATEST_VERSION}/${JAR_FILENAME}"
|
|
||||||
|
|
||||||
# Create the plugins directory if it doesn't exist
|
|
||||||
mkdir -p "$PLUGIN_DIR"
|
|
||||||
|
|
||||||
# Remove any existing youtube-plugin JARs to avoid conflicts
|
|
||||||
echo "Removing old plugin versions from $PLUGIN_DIR..."
|
|
||||||
rm -f "$PLUGIN_DIR"/youtube-plugin-*.jar
|
|
||||||
|
|
||||||
# Download the latest snapshot JAR
|
|
||||||
echo "Downloading $JAR_FILENAME from $DOWNLOAD_URL..."
|
|
||||||
curl -L -o "$PLUGIN_DIR/$JAR_FILENAME" "$DOWNLOAD_URL"
|
|
||||||
|
|
||||||
# Verify download
|
|
||||||
if [ ! -f "$PLUGIN_DIR/$JAR_FILENAME" ]; then
|
|
||||||
echo "Error: Failed to download the plugin JAR."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Successfully downloaded $JAR_FILENAME to $PLUGIN_DIR"
|
|
||||||
echo "Make sure to restart your Lavalink container for the changes to take effect."
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
Loading…
x
Reference in New Issue
Block a user