Compare commits
32 Commits
0d0125bf55
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a2c9121012 | |||
| 1aa97a8a7a | |||
| c613ef3f35 | |||
| 9fd3f4a678 | |||
| a324815788 | |||
| 72a59bbcdd | |||
| c42e0931d6 | |||
| 228d0bef69 | |||
| 3c4dc51855 | |||
| 75185a59c3 | |||
| 5b51e3f529 | |||
| 99a1417c43 | |||
| 6546cb8d63 | |||
| b958e79a98 | |||
| 81c65a3644 | |||
| 68a3f4fb58 | |||
| 5c5574c06e | |||
| 9e02e50693 | |||
| 9d7ff5e7e7 | |||
| 3ba230e6e9 | |||
| 253f369a89 | |||
| 7500ea01c8 | |||
| f31bba40fb | |||
| 4d5c301c46 | |||
| 8f8ff6aa81 | |||
| ca9e531541 | |||
| f1991f7716 | |||
| ce635cb32b | |||
| 30b5b23868 | |||
| 74cac2bfbb | |||
| bb7a796cf9 | |||
| a54becb3a0 |
19
.env.example
19
.env.example
@@ -1,4 +1,21 @@
|
|||||||
|
# Discord Bot Token
|
||||||
DISCORD_TOKEN=your_token_here
|
DISCORD_TOKEN=your_token_here
|
||||||
LAVALINK_HOST=127.0.0.1
|
|
||||||
|
# Discord Application Client ID (for command deployment)
|
||||||
|
CLIENT_ID=your_client_id_here
|
||||||
|
|
||||||
|
# Discord Guild ID (optional, for deploying commands to a specific test server)
|
||||||
|
# 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_PORT=2333
|
LAVALINK_PORT=2333
|
||||||
LAVALINK_PASSWORD=your_password_here
|
LAVALINK_PASSWORD=your_password_here
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -18,3 +18,6 @@ pnpm-lock.yaml
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Data directory
|
||||||
|
data/
|
||||||
|
|||||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
coverage
|
||||||
|
build
|
||||||
|
*.d.ts
|
||||||
10
.prettierrc.json
Normal file
10
.prettierrc.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
34
Dockerfile
34
Dockerfile
@@ -1,15 +1,43 @@
|
|||||||
FROM node:18-alpine
|
# ---- Build Stage ----
|
||||||
|
FROM node:23-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm and necessary build tools
|
||||||
RUN apk add --no-cache python3 make g++ pnpm
|
RUN apk add --no-cache python3 make g++ pnpm
|
||||||
|
|
||||||
|
# First copy all config files
|
||||||
|
COPY tsconfig.json tsconfig.deploy.json ./
|
||||||
COPY package.json pnpm-lock.yaml ./
|
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
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
# 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
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
CMD ["node", "src/index.js"]
|
# Copy application files
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY --from=builder /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
COPY application.yml ./application.yml
|
||||||
|
COPY plugins ./plugins
|
||||||
|
|
||||||
|
# Install production dependencies only
|
||||||
|
# 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"]
|
||||||
|
|||||||
194
README.md
194
README.md
@@ -1,69 +1,167 @@
|
|||||||
# discord-music-bot
|
# Discord Music Bot (TypeScript)
|
||||||
|
|
||||||
Discord music bot template written in NodeJS using `discord.js` and `erela.js`, with Lavalink support.
|
Discord music bot template written in TypeScript using `discord.js` and `shoukaku`, with Lavalink support.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Slash commands: `/ping`, `/join`, `/play`, `/leave`
|
- Slash commands (e.g., `/ping`, `/join`, `/play`, `/leave`)
|
||||||
- Lavalink integration for audio playback
|
- `shoukaku` integration for robust Lavalink audio playback
|
||||||
- Modular command handler structure
|
- Modular command and event handlers written in TypeScript
|
||||||
|
- Basic Docker support (`Dockerfile`, `docker-compose.yml`)
|
||||||
|
- Comprehensive test suite with Jest
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js (>=14)
|
- Node.js (>=16 recommended, check `package.json` for specific engine requirements)
|
||||||
- pnpm or npm
|
- pnpm (recommended) or npm
|
||||||
- A Discord application with bot token
|
- TypeScript (`typescript` package, usually installed as a dev dependency)
|
||||||
- LavaLink server for audio streaming
|
- A Discord application with bot token and client ID
|
||||||
|
- A running Lavalink server
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env` and fill in your credentials:
|
1. **Clone the repository:**
|
||||||
```env
|
```sh
|
||||||
DISCORD_TOKEN=your_discord_bot_token
|
git clone <repository_url>
|
||||||
CLIENT_ID=your_discord_application_id
|
cd discord-music-bot
|
||||||
LAVALINK_HOST=127.0.0.1
|
```
|
||||||
LAVALINK_PORT=2333
|
2. **Install dependencies:**
|
||||||
LAVALINK_PASSWORD=your_lavalink_password
|
```sh
|
||||||
```
|
pnpm install
|
||||||
2. Install dependencies:
|
```
|
||||||
```sh
|
3. **Configure Environment:**
|
||||||
pnpm install
|
Copy `.env.example` to `.env` and fill in your credentials:
|
||||||
```
|
```dotenv
|
||||||
3. Run tests:
|
# Discord Bot Token (Required)
|
||||||
```sh
|
DISCORD_TOKEN=your_discord_bot_token
|
||||||
pnpm test
|
|
||||||
```
|
# Discord Application Client ID (Required for command deployment)
|
||||||
4. Register slash commands:
|
CLIENT_ID=your_discord_application_id
|
||||||
```sh
|
|
||||||
pnpm start # or node deploy-commands.js
|
# Discord Guild ID (Optional, for deploying commands to a specific test server)
|
||||||
```
|
# GUILD_ID=your_guild_id_here
|
||||||
5. Start the bot:
|
|
||||||
```sh
|
# Lavalink Configuration (Required)
|
||||||
pnpm start
|
LAVALINK_HOST=lavalink # Or 127.0.0.1 if running locally without Docker Compose
|
||||||
```
|
LAVALINK_PORT=2333
|
||||||
|
LAVALINK_PASSWORD=your_lavalink_password
|
||||||
|
# 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:**
|
||||||
|
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):**
|
||||||
|
Many setups use `ts-node` for development, but for production, you might need to compile:
|
||||||
|
```sh
|
||||||
|
pnpm build # Check package.json for the exact build script
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Register Slash Commands:**
|
||||||
|
Run the deployment script (ensure `CLIENT_ID` and `DISCORD_TOKEN` are set in `.env`).
|
||||||
|
```sh
|
||||||
|
pnpm deploy # Check package.json for the exact deploy script (might be node/ts-node deploy-commands.ts)
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Start the Bot:**
|
||||||
|
```sh
|
||||||
|
pnpm start # Check package.json for the exact start script (might run compiled JS or use ts-node)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The project includes a comprehensive test suite using Jest. The tests cover commands, events, and utilities.
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests with coverage report
|
||||||
|
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
|
## Docker
|
||||||
|
|
||||||
A `Dockerfile` and `docker-compose.yml` are provided for containerized deployment.
|
A `Dockerfile` and `docker-compose.yml` are provided for containerized deployment.
|
||||||
|
|
||||||
- Build and run with Docker Compose:
|
- Ensure your `.env` file is configured correctly.
|
||||||
```sh
|
- Build and run with Docker Compose:
|
||||||
docker-compose up --build
|
```sh
|
||||||
```
|
docker-compose up --build -d # Use -d to run in detached mode
|
||||||
- Environment variables are loaded from `.env`.
|
```
|
||||||
- Lavalink service is configured in `docker-compose.yml` alongside the bot.
|
- The `docker-compose.yml` includes both the bot service and a Lavalink service.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- `src/index.js` — Entry point
|
```
|
||||||
- `src/commands/` — Slash command modules
|
.
|
||||||
- `src/events/` — Discord event handlers
|
├── src/ # Source code directory
|
||||||
- `src/structures/` — Erela.js (Lavalink) event wiring
|
│ ├── commands/ # Slash command modules (.ts)
|
||||||
- `src/utils/logger.js` — Logging setup
|
│ ├── events/ # Discord.js and Shoukaku event handlers (.ts)
|
||||||
- `deploy-commands.js` — Slash command registration script
|
│ ├── structures/ # Custom structures or base classes (e.g., Shoukaku event handlers)
|
||||||
- `Dockerfile` — Bot container image
|
│ └── utils/ # Utility functions (e.g., logger.ts)
|
||||||
- `docker-compose.yml` — Multi-service setup (bot + Lavalink)
|
├── tests/ # Test files (see Testing section)
|
||||||
|
├── plugins/ # Lavalink plugins (e.g., youtube-plugin-*.jar)
|
||||||
|
├── .env.example # Example environment variables
|
||||||
|
├── application.yml # Lavalink server configuration
|
||||||
|
├── deploy-commands.ts # Script to register slash commands
|
||||||
|
├── docker-compose.yml # Docker Compose configuration
|
||||||
|
├── Dockerfile # Dockerfile for building the bot image
|
||||||
|
├── jest.config.ts # Jest test configuration
|
||||||
|
├── package.json # Node.js project manifest
|
||||||
|
├── tsconfig.json # TypeScript compiler options
|
||||||
|
└── update-plugin.sh # Script to update Lavalink plugins
|
||||||
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT
|
This project is licensed under the **GNU General Public License v3.0**. See the [LICENSE](LICENSE) file for details.
|
||||||
|
|||||||
130
application.yml
130
application.yml
@@ -1,30 +1,9 @@
|
|||||||
server:
|
server: # REST and WS server
|
||||||
port: 2333
|
port: 2333
|
||||||
address: 0.0.0.0
|
address: 0.0.0.0
|
||||||
password: ${LAVALINK_PASSWORD:changeme} # Uses env var or defaults to 'changeme'
|
http2:
|
||||||
|
enabled: false # Whether to enable HTTP/2 support
|
||||||
logging:
|
# Root level plugin configuration block
|
||||||
level:
|
|
||||||
root: INFO
|
|
||||||
lavalink: INFO
|
|
||||||
|
|
||||||
lavalink:
|
|
||||||
server:
|
|
||||||
sources:
|
|
||||||
youtube: false
|
|
||||||
bandcamp: true
|
|
||||||
soundcloud: true
|
|
||||||
twitch: true
|
|
||||||
vimeo: true
|
|
||||||
http: true
|
|
||||||
local: false
|
|
||||||
resamplingQuality: LOW
|
|
||||||
bufferDurationMs: 400
|
|
||||||
frameBufferDurationMs: 5000
|
|
||||||
gc-warnings: true
|
|
||||||
plugins:
|
|
||||||
- dependency: "dev.lavalink.youtube:youtube-plugin:1.12.0" # Use the new YouTube source plugin
|
|
||||||
|
|
||||||
plugins:
|
plugins:
|
||||||
youtube:
|
youtube:
|
||||||
enabled: true # Whether this source can be used.
|
enabled: true # Whether this source can be used.
|
||||||
@@ -34,7 +13,104 @@ plugins:
|
|||||||
# The clients to use for track loading. See below for a list of valid clients.
|
# 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 are queried in the order they are given (so the first client is queried first and so on...)
|
||||||
clients:
|
clients:
|
||||||
- MUSIC
|
|
||||||
- ANDROID_VR
|
|
||||||
- WEB
|
- WEB
|
||||||
- WEBEMBEDDED
|
- 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:
|
||||||
|
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:
|
||||||
|
password: "${LAVALINK_PASSWORD}" # Use environment variable
|
||||||
|
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: false
|
||||||
|
bandcamp: false
|
||||||
|
soundcloud: false
|
||||||
|
twitch: false
|
||||||
|
vimeo: false
|
||||||
|
nico: false
|
||||||
|
http: false # warning: keeping HTTP enabled without a proxy configured could expose your server's IP address.
|
||||||
|
local: false
|
||||||
|
filters: # All filters are enabled by default
|
||||||
|
volume: true
|
||||||
|
equalizer: true
|
||||||
|
karaoke: true
|
||||||
|
timescale: true
|
||||||
|
tremolo: true
|
||||||
|
vibrato: true
|
||||||
|
distortion: true
|
||||||
|
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:
|
||||||
|
file:
|
||||||
|
path: ./logs/
|
||||||
|
|
||||||
|
level:
|
||||||
|
root: 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
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
const { REST, Routes } = require('discord.js');
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const logger = require('./src/utils/logger'); // Assuming logger is setup
|
|
||||||
require('dotenv').config(); // Load .env variables
|
|
||||||
|
|
||||||
console.log('CLIENT_ID: ', process.env.CLIENT_ID ? 'Present' : process.env.CLIENT_ID);
|
|
||||||
console.log('DISCORD_TOKEN:', process.env.DISCORD_TOKEN ? 'Present' : process.env.DISCORD_TOKEN);
|
|
||||||
|
|
||||||
// --- Configuration ---
|
|
||||||
const clientId = process.env.CLIENT_ID;
|
|
||||||
const token = process.env.DISCORD_TOKEN;
|
|
||||||
// const guildId = process.env.GUILD_ID; // Uncomment for guild-specific commands during testing
|
|
||||||
|
|
||||||
if (!clientId || !token) {
|
|
||||||
logger.error('Missing CLIENT_ID or DISCORD_TOKEN in .env file for command deployment!');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const commands = [];
|
|
||||||
// Grab all the command files from the commands directory you created earlier
|
|
||||||
const commandsPath = path.join(__dirname, 'src', 'commands');
|
|
||||||
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
|
|
||||||
|
|
||||||
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
|
|
||||||
logger.info(`Started loading ${commandFiles.length} application (/) commands for deployment.`);
|
|
||||||
for (const file of commandFiles) {
|
|
||||||
const filePath = path.join(commandsPath, file);
|
|
||||||
try {
|
|
||||||
const command = require(filePath);
|
|
||||||
if ('data' in command && 'execute' in command) {
|
|
||||||
commands.push(command.data.toJSON());
|
|
||||||
logger.info(`Loaded command: ${command.data.name}`);
|
|
||||||
} else {
|
|
||||||
logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error loading command at ${filePath} for deployment: ${error.message}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct and prepare an instance of the REST module
|
|
||||||
const rest = new REST({ version: '10' }).setToken(token);
|
|
||||||
|
|
||||||
// and deploy your commands!
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
logger.info(`Started wiping all global and guild application (/) commands.`);
|
|
||||||
|
|
||||||
// 1. Wipe Global Commands
|
|
||||||
await rest.put(
|
|
||||||
Routes.applicationCommands(clientId),
|
|
||||||
{ body: [] }
|
|
||||||
);
|
|
||||||
logger.info('Successfully wiped all global application commands.');
|
|
||||||
|
|
||||||
// 2. Wipe Guild Commands (optional but recommended for dev/testing guilds)
|
|
||||||
const guildId = process.env.GUILD_ID; // Make sure this is set
|
|
||||||
if (guildId) {
|
|
||||||
await rest.put(
|
|
||||||
Routes.applicationGuildCommands(clientId, guildId),
|
|
||||||
{ body: [] }
|
|
||||||
);
|
|
||||||
logger.info(`Successfully wiped all application commands in guild ${guildId}.`);
|
|
||||||
} else {
|
|
||||||
logger.warn('GUILD_ID not set; skipping guild command wipe.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Register New Global Commands
|
|
||||||
logger.info(`Registering ${commands.length} new global commands...`);
|
|
||||||
const data = await rest.put(
|
|
||||||
Routes.applicationCommands(clientId),
|
|
||||||
{ body: commands },
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.info(`Successfully registered ${data.length} new global commands.`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed during command reset and deployment:', error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
108
deploy-commands.ts
Normal file
108
deploy-commands.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
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.
|
||||||
@@ -9,8 +9,20 @@ services:
|
|||||||
- "2333:2333"
|
- "2333:2333"
|
||||||
environment:
|
environment:
|
||||||
- LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD}
|
- LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD}
|
||||||
|
# Removed LAVALINK_PLUGIN_URLS environment variable
|
||||||
volumes:
|
volumes:
|
||||||
- ./application.yml:/opt/Lavalink/application.yml:ro,Z
|
- ./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:
|
bot:
|
||||||
build: .
|
build: .
|
||||||
@@ -21,11 +33,13 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- LAVALINK_HOST=lavalink
|
LAVALINK_HOST: lavalink
|
||||||
- LAVALINK_PORT=2333
|
LAVALINK_PORT: 2333
|
||||||
- LAVALINK_PASSWORD=${LAVALINK_PASSWORD}
|
LAVALINK_PASSWORD: ${LAVALINK_PASSWORD}
|
||||||
|
# Update depends_on to wait for healthcheck
|
||||||
depends_on:
|
depends_on:
|
||||||
- lavalink
|
lavalink:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
bot-network:
|
bot-network:
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -2,10 +2,17 @@
|
|||||||
"name": "discord-music-bot",
|
"name": "discord-music-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "dist/index.js",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"build": "tsc -p tsconfig.json",
|
||||||
"test": "jest"
|
"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": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@@ -17,7 +24,13 @@
|
|||||||
"winston": "^3.17.0"
|
"winston": "^3.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"jest": "^29.7.0",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"js-yaml": "^4.1.0"
|
"@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.
60
scripts/fix-imports.cjs
Executable file
60
scripts/fix-imports.cjs
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
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();
|
||||||
60
scripts/fix-imports.js
Executable file
60
scripts/fix-imports.js
Executable file
@@ -0,0 +1,60 @@
|
|||||||
|
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,88 +0,0 @@
|
|||||||
const { SlashCommandBuilder, PermissionFlagsBits, ChannelType, MessageFlags } = require('discord.js'); // Import MessageFlags
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName('join')
|
|
||||||
.setDescription('Joins your current voice channel'),
|
|
||||||
async execute(interaction, client) { // Added client parameter
|
|
||||||
// Use flags for ephemeral deferral
|
|
||||||
await interaction.deferReply({ flags: MessageFlags.Ephemeral });
|
|
||||||
|
|
||||||
const member = interaction.member;
|
|
||||||
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!');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check bot permissions
|
|
||||||
const permissions = voiceChannel.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.) although erela might handle this
|
|
||||||
if (voiceChannel.type !== ChannelType.GuildVoice) {
|
|
||||||
return interaction.editReply('I can only join standard voice channels.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the initialized Shoukaku player manager from the client object
|
|
||||||
const musicPlayer = interaction.client.player;
|
|
||||||
if (!musicPlayer) {
|
|
||||||
logger.error('Music player not initialized 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 = musicPlayer.getPlayer(interaction.guildId);
|
|
||||||
|
|
||||||
if (!player) {
|
|
||||||
try {
|
|
||||||
// Create player using the Shoukaku manager
|
|
||||||
player = await musicPlayer.createPlayer({
|
|
||||||
guildId: interaction.guildId,
|
|
||||||
textChannel: interaction.channelId,
|
|
||||||
voiceChannel: voiceChannel.id
|
|
||||||
});
|
|
||||||
// Connection is handled within createPlayer
|
|
||||||
logger.info(`Created player and connected to voice channel ${voiceChannel.name} (${voiceChannel.id}) in guild ${interaction.guild.name} (${interaction.guildId})`);
|
|
||||||
await interaction.editReply(`Joined ${voiceChannel.name}! Ready to play music.`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to create/connect player for guild ${interaction.guildId}: ${error.message}`, error);
|
|
||||||
// Player destruction is handled internally if creation fails or via destroy method
|
|
||||||
return interaction.editReply('An error occurred while trying to join the voice channel.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If player exists but is in a different channel
|
|
||||||
if (player.voiceChannel !== voiceChannel.id) {
|
|
||||||
// Destroy the old player and create a new one in the correct channel
|
|
||||||
player.destroy();
|
|
||||||
try {
|
|
||||||
player = await musicPlayer.createPlayer({
|
|
||||||
guildId: interaction.guildId,
|
|
||||||
textChannel: interaction.channelId,
|
|
||||||
voiceChannel: voiceChannel.id
|
|
||||||
});
|
|
||||||
logger.info(`Moved player to voice channel ${voiceChannel.name} (${voiceChannel.id}) in guild ${interaction.guildId}`);
|
|
||||||
await interaction.editReply(`Moved to ${voiceChannel.name}!`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to move player for guild ${interaction.guildId}: ${error.message}`, error);
|
|
||||||
return interaction.editReply('An error occurred while trying to move to the voice channel.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Already in the correct channel
|
|
||||||
await interaction.editReply(`I'm already in ${voiceChannel.name}!`);
|
|
||||||
}
|
|
||||||
// Update text channel if needed (Shoukaku player object stores textChannel)
|
|
||||||
if (player.textChannel !== interaction.channelId) {
|
|
||||||
player.textChannel = interaction.channelId; // Directly update the property
|
|
||||||
logger.debug(`Updated player text channel to ${interaction.channel.name} (${interaction.channelId}) in guild ${interaction.guildId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
127
src/commands/join.ts
Normal file
127
src/commands/join.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
const { SlashCommandBuilder } = require('discord.js');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName('leave')
|
|
||||||
.setDescription('Leaves the current voice channel'),
|
|
||||||
async execute(interaction, client) { // Added client parameter
|
|
||||||
await interaction.deferReply({ ephemeral: true });
|
|
||||||
|
|
||||||
const player = client.manager.get(interaction.guildId);
|
|
||||||
|
|
||||||
// Check if the player exists and the bot is in a voice channel
|
|
||||||
if (!player || !player.voiceChannel) {
|
|
||||||
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 memberVoiceChannel = interaction.member?.voice?.channelId;
|
|
||||||
// if (memberVoiceChannel !== player.voiceChannel) {
|
|
||||||
// return interaction.editReply('You need to be in the same voice channel as me to make me leave!');
|
|
||||||
// }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const channelId = player.voiceChannel;
|
|
||||||
const channel = client.channels.cache.get(channelId);
|
|
||||||
const channelName = channel ? channel.name : `ID: ${channelId}`; // Get channel name if possible
|
|
||||||
|
|
||||||
player.destroy(); // Disconnects, clears queue, and destroys the player instance
|
|
||||||
logger.info(`Player destroyed and left voice channel ${channelName} in guild ${interaction.guild.name} (${interaction.guildId}) by user ${interaction.user.tag}`);
|
|
||||||
await interaction.editReply(`Left ${channelName}.`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error destroying player for guild ${interaction.guildId}: ${error.message}`, error);
|
|
||||||
// Attempt to reply even if destroy failed partially
|
|
||||||
await interaction.editReply('An error occurred while trying to leave the voice channel.').catch(e => logger.error(`Failed to send error reply for leave command: ${e.message}`));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
81
src/commands/leave.ts
Normal file
81
src/commands/leave.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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,15 +0,0 @@
|
|||||||
const { SlashCommandBuilder } = require('discord.js');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName('ping')
|
|
||||||
.setDescription('Replies with Pong!'),
|
|
||||||
async execute(interaction) {
|
|
||||||
// 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`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
22
src/commands/ping.ts
Normal file
22
src/commands/ping.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
const { SlashCommandBuilder, PermissionFlagsBits, ChannelType, EmbedBuilder } = require('discord.js');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
// Removed direct import of musicPlayer
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
data: new SlashCommandBuilder()
|
|
||||||
.setName('play')
|
|
||||||
.setDescription('Plays audio from a URL or search query')
|
|
||||||
.addStringOption(option =>
|
|
||||||
option.setName('query')
|
|
||||||
.setDescription('The URL or search term for the song/playlist')
|
|
||||||
.setRequired(true)),
|
|
||||||
async execute(interaction, client) {
|
|
||||||
await interaction.deferReply(); // Defer reply immediately
|
|
||||||
|
|
||||||
const member = interaction.member;
|
|
||||||
const voiceChannel = member?.voice?.channel;
|
|
||||||
const query = interaction.options.getString('query');
|
|
||||||
|
|
||||||
// 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!');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Check bot permissions
|
|
||||||
const permissions = voiceChannel.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 (voiceChannel.type !== ChannelType.GuildVoice) {
|
|
||||||
return interaction.editReply('I can only join standard voice channels.');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the initialized player from the client object
|
|
||||||
const musicPlayer = interaction.client.player;
|
|
||||||
if (!musicPlayer) {
|
|
||||||
logger.error('Music player not initialized on client object!');
|
|
||||||
return interaction.editReply('The music player is not ready yet. Please try again shortly.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Get or create player
|
|
||||||
let player = musicPlayer.getPlayer(interaction.guildId);
|
|
||||||
if (!player) {
|
|
||||||
try {
|
|
||||||
player = await musicPlayer.createPlayer({
|
|
||||||
guildId: interaction.guildId,
|
|
||||||
textChannel: interaction.channelId, // Use interaction.channelId directly
|
|
||||||
voiceChannel: voiceChannel.id // Use voiceChannel.id directly
|
|
||||||
});
|
|
||||||
logger.info(`Created player and connected to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to create/connect player for guild ${interaction.guildId} during play command: ${error.message}`);
|
|
||||||
return interaction.editReply('An error occurred while trying to join the voice channel.');
|
|
||||||
}
|
|
||||||
} else if (player.voiceChannel !== voiceChannel.id) {
|
|
||||||
// If player exists but in a different voice channel, destroy it and create a new one
|
|
||||||
player.destroy();
|
|
||||||
player = await musicPlayer.createPlayer({
|
|
||||||
guildId: interaction.guildId,
|
|
||||||
textChannel: interaction.channelId,
|
|
||||||
voiceChannel: voiceChannel.id
|
|
||||||
});
|
|
||||||
logger.info(`Moved player to voice channel ${voiceChannel.name} (${voiceChannel.id}) for play command.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Search for tracks
|
|
||||||
const searchResults = await musicPlayer.search({ // Use the player instance from the client
|
|
||||||
query: query,
|
|
||||||
requester: interaction.user
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!searchResults || searchResults.length === 0) {
|
|
||||||
await interaction.editReply(`No results found for "${query}".`);
|
|
||||||
if (!player.playing && player.queue.length === 0) {
|
|
||||||
player.destroy();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Add track(s) to queue and create response embed
|
|
||||||
const responseEmbed = new EmbedBuilder().setColor('#0099ff');
|
|
||||||
|
|
||||||
// Add first track (or all tracks if it's a playlist)
|
|
||||||
const firstTrack = searchResults[0];
|
|
||||||
|
|
||||||
// Detect if it's a playlist based on number of tracks
|
|
||||||
const isPlaylist = searchResults.length > 1 &&
|
|
||||||
searchResults[0].info.uri.includes('playlist');
|
|
||||||
|
|
||||||
if (isPlaylist) {
|
|
||||||
// Add all tracks to the queue
|
|
||||||
for (const track of searchResults) {
|
|
||||||
await player.enqueue(track);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up playlist embed
|
|
||||||
responseEmbed
|
|
||||||
.setTitle('Playlist Added to Queue')
|
|
||||||
.setDescription(`**Playlist** (${searchResults.length} tracks)`)
|
|
||||||
.addFields({ name: 'Starting track', value: `[${firstTrack.info.title}](${firstTrack.info.uri})` });
|
|
||||||
|
|
||||||
logger.info(`Added playlist with ${searchResults.length} tracks to queue (Guild: ${interaction.guildId})`);
|
|
||||||
} else {
|
|
||||||
// Add single track to queue
|
|
||||||
await player.enqueue(firstTrack);
|
|
||||||
|
|
||||||
// Set up track embed
|
|
||||||
responseEmbed
|
|
||||||
.setTitle('Track Added to Queue')
|
|
||||||
.setDescription(`[${firstTrack.info.title}](${firstTrack.info.uri})`)
|
|
||||||
.addFields({ name: 'Position in queue', value: `${player.queue.length}`, inline: true });
|
|
||||||
|
|
||||||
// Add thumbnail if available
|
|
||||||
if (firstTrack.info.thumbnail) {
|
|
||||||
responseEmbed.setThumbnail(firstTrack.info.thumbnail);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Added track to queue: ${firstTrack.info.title} (Guild: ${interaction.guildId})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send response
|
|
||||||
await interaction.editReply({ embeds: [responseEmbed] });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error during search/play for query "${query}" in guild ${interaction.guildId}: ${error.message}`);
|
|
||||||
await interaction.editReply('An unexpected error occurred while trying to play the music.').catch(e =>
|
|
||||||
logger.error(`Failed to send error reply for play command: ${e.message}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Executed command 'play' for user ${interaction.user.tag}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
445
src/commands/play.ts
Normal file
445
src/commands/play.ts
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
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(() => {});
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
const { Events, InteractionType } = require('discord.js');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
name: Events.InteractionCreate,
|
|
||||||
async execute(interaction, client) { // Added client parameter
|
|
||||||
// Handle only slash commands (ChatInputCommand) for now
|
|
||||||
if (!interaction.isChatInputCommand()) return;
|
|
||||||
|
|
||||||
const command = client.commands.get(interaction.commandName);
|
|
||||||
|
|
||||||
if (!command) {
|
|
||||||
logger.error(`No command matching ${interaction.commandName} was found.`);
|
|
||||||
try {
|
|
||||||
await interaction.reply({ content: 'Error: This command was not found!', ephemeral: true });
|
|
||||||
} catch (replyError) {
|
|
||||||
logger.error(`Failed to send 'command not found' reply: ${replyError.message}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Execute the command's logic
|
|
||||||
await command.execute(interaction, client); // Pass client to command execute
|
|
||||||
logger.info(`Executed command '${interaction.commandName}' for user ${interaction.user.tag}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error executing command '${interaction.commandName}': ${error.message}`, error);
|
|
||||||
// Try to reply to the interaction, otherwise edit the deferred reply if applicable
|
|
||||||
const replyOptions = { content: 'There was an error while executing this command!', ephemeral: true };
|
|
||||||
try {
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
|
||||||
await interaction.followUp(replyOptions);
|
|
||||||
} else {
|
|
||||||
await interaction.reply(replyOptions);
|
|
||||||
}
|
|
||||||
} catch (replyError) {
|
|
||||||
logger.error(`Failed to send error reply for command '${interaction.commandName}': ${replyError.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
37
src/events/interactionCreate.ts
Normal file
37
src/events/interactionCreate.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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,23 +0,0 @@
|
|||||||
const { Events, ActivityType } = require('discord.js');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
const { setupPlayer } = require('../structures/ShoukakuEvents'); // Import the Shoukaku player
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
name: Events.ClientReady,
|
|
||||||
once: true, // This event should only run once
|
|
||||||
async execute(client) {
|
|
||||||
logger.info(`Ready! Logged in as ${client.user.tag}`);
|
|
||||||
|
|
||||||
// Initialize the Shoukaku music player
|
|
||||||
try {
|
|
||||||
// Set up the music player with the client
|
|
||||||
client.player = setupPlayer(client);
|
|
||||||
logger.info('Shoukaku music player initialized successfully');
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to initialize Shoukaku music player: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set activity status
|
|
||||||
client.user.setActivity('Music | /play', { type: ActivityType.Listening });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
34
src/events/ready.ts
Normal file
34
src/events/ready.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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,52 +0,0 @@
|
|||||||
const { Events } = require('discord.js');
|
|
||||||
const logger = require('../utils/logger');
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
name: Events.VoiceStateUpdate,
|
|
||||||
execute(oldState, newState, client) { // Added client parameter
|
|
||||||
// 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 musicPlayer = client.player;
|
|
||||||
if (!musicPlayer) {
|
|
||||||
// Player manager might not be ready yet, especially during startup.
|
|
||||||
// logger.debug('Voice state update received, but Shoukaku player manager is not ready yet.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const player = musicPlayer.getPlayer(newState.guild.id);
|
|
||||||
if (!player) return; // No active player for this guild
|
|
||||||
|
|
||||||
// Check if the bot was disconnected (newState has no channelId for the bot)
|
|
||||||
if (newState.id === client.user.id && !newState.channelId && oldState.channelId === player.voiceChannel) {
|
|
||||||
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 = client.channels.cache.get(player.voiceChannel);
|
|
||||||
// Ensure the channel exists and the update is relevant to the bot's channel
|
|
||||||
if (channel && (newState.channelId === player.voiceChannel || oldState.channelId === player.voiceChannel)) {
|
|
||||||
// Fetch members again to ensure freshness after the update
|
|
||||||
const members = channel.members;
|
|
||||||
if (members.size === 1 && members.has(client.user.id)) {
|
|
||||||
logger.info(`Voice channel ${channel.name} (${player.voiceChannel}) in guild ${newState.guild.id} is now empty (only bot left). Destroying player.`);
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
74
src/events/voiceStateUpdate.ts
Normal file
74
src/events/voiceStateUpdate.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
110
src/index.js
110
src/index.js
@@ -1,110 +0,0 @@
|
|||||||
// Load environment variables from .env file
|
|
||||||
require('dotenv').config();
|
|
||||||
const { Client, GatewayIntentBits, Collection } = require('discord.js');
|
|
||||||
const { Shoukaku, Connectors } = require('shoukaku');
|
|
||||||
const logger = require('./utils/logger');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
// 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.');
|
|
||||||
// Decide if the bot should exit or continue without music
|
|
||||||
// process.exit(1); // Uncomment to exit if Lavalink is mandatory
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new Discord client instance with necessary intents
|
|
||||||
const client = new Client({
|
|
||||||
intents: [
|
|
||||||
GatewayIntentBits.Guilds,
|
|
||||||
GatewayIntentBits.GuildVoiceStates,
|
|
||||||
GatewayIntentBits.GuildMessages, // Add if needed for prefix commands or message content
|
|
||||||
GatewayIntentBits.MessageContent, // Add if needed for message content
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Define Shoukaku nodes - fix the URL format to properly connect to Lavalink
|
|
||||||
const Nodes = [
|
|
||||||
{
|
|
||||||
name: 'lavalink',
|
|
||||||
url: `${process.env.LAVALINK_HOST || 'localhost'}:${process.env.LAVALINK_PORT || '2333'}`,
|
|
||||||
auth: process.env.LAVALINK_PASSWORD || 'youshallnotpass',
|
|
||||||
secure: process.env.LAVALINK_SECURE === 'true'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Initialize Shoukaku with proper configuration
|
|
||||||
client.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), Nodes, {
|
|
||||||
moveOnDisconnect: false,
|
|
||||||
resume: true,
|
|
||||||
reconnectTries: 10,
|
|
||||||
reconnectInterval: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show the actual Lavalink connection details (without exposing the actual password)
|
|
||||||
logger.info(`Lavalink connection configured to: ${process.env.LAVALINK_HOST}:${process.env.LAVALINK_PORT} (Password: ${process.env.LAVALINK_PASSWORD ? '[SET]' : '[NOT SET]'})`);
|
|
||||||
|
|
||||||
// Collections for commands
|
|
||||||
client.commands = new Collection();
|
|
||||||
|
|
||||||
// --- Command Loading ---
|
|
||||||
const commandsPath = path.join(__dirname, 'commands');
|
|
||||||
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
|
|
||||||
|
|
||||||
for (const file of commandFiles) {
|
|
||||||
const filePath = path.join(commandsPath, file);
|
|
||||||
try {
|
|
||||||
const command = require(filePath);
|
|
||||||
// Set a new item in the Collection with the key as the command name and the value as the exported module
|
|
||||||
if ('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.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error loading command at ${filePath}: ${error.message}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Event Handling ---
|
|
||||||
const eventsPath = path.join(__dirname, 'events');
|
|
||||||
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js'));
|
|
||||||
|
|
||||||
for (const file of eventFiles) {
|
|
||||||
const filePath = path.join(eventsPath, file);
|
|
||||||
const event = require(filePath);
|
|
||||||
if (event.once) {
|
|
||||||
client.once(event.name, (...args) => event.execute(...args, client)); // Pass client to event handlers
|
|
||||||
logger.info(`Loaded event ${event.name} (once)`);
|
|
||||||
} else {
|
|
||||||
client.on(event.name, (...args) => event.execute(...args, client)); // Pass client to event handlers
|
|
||||||
logger.info(`Loaded event ${event.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Shoukaku Event Handling ---
|
|
||||||
// Set up Shoukaku event handlers
|
|
||||||
client.shoukaku.on('ready', (name) => logger.info(`Lavalink Node: ${name} is now connected`));
|
|
||||||
client.shoukaku.on('error', (name, error) => logger.error(`Lavalink Node: ${name} emitted an error: ${error.message}`));
|
|
||||||
client.shoukaku.on('close', (name, code, reason) => logger.warn(`Lavalink Node: ${name} closed with code ${code}. Reason: ${reason || 'No reason'}`));
|
|
||||||
client.shoukaku.on('disconnect', (name, reason) => logger.warn(`Lavalink Node: ${name} disconnected. Reason: ${reason || 'No reason'}`));
|
|
||||||
|
|
||||||
// Log in to Discord with your client's token
|
|
||||||
client.login(process.env.DISCORD_TOKEN)
|
|
||||||
.then(() => logger.info('Successfully logged in to Discord.'))
|
|
||||||
.catch(error => logger.error(`Failed to log in: ${error.message}`));
|
|
||||||
|
|
||||||
// Basic error handling
|
|
||||||
process.on('unhandledRejection', error => {
|
|
||||||
logger.error('Unhandled promise rejection:', error);
|
|
||||||
});
|
|
||||||
process.on('uncaughtException', error => {
|
|
||||||
logger.error('Uncaught exception:', error);
|
|
||||||
// Optional: exit process on critical uncaught exceptions
|
|
||||||
// process.exit(1);
|
|
||||||
});
|
|
||||||
200
src/index.ts
Normal file
200
src/index.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
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 });
|
||||||
|
});
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
const logger = require('../utils/logger');
|
|
||||||
const { EmbedBuilder } = require('discord.js'); // Import EmbedBuilder
|
|
||||||
|
|
||||||
module.exports = (client) => {
|
|
||||||
if (!client || !client.manager) {
|
|
||||||
logger.error("ErelaEvents requires a client with an initialized manager.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.manager
|
|
||||||
.on('nodeConnect', node => logger.info(`Node "${node.options.identifier}" connected.`))
|
|
||||||
.on('nodeError', (node, error) => logger.error(`Node "${node.options.identifier}" encountered an error: ${error.message}`))
|
|
||||||
.on('nodeDisconnect', node => logger.warn(`Node "${node.options.identifier}" disconnected.`))
|
|
||||||
.on('nodeReconnect', node => logger.info(`Node "${node.options.identifier}" reconnecting.`))
|
|
||||||
|
|
||||||
.on('trackStart', (player, track) => {
|
|
||||||
logger.info(`Track started in guild ${player.guild}: ${track.title} requested by ${track.requester?.tag || 'Unknown'}`);
|
|
||||||
|
|
||||||
// Find the text channel associated with the player (if stored)
|
|
||||||
const channel = client.channels.cache.get(player.textChannel);
|
|
||||||
if (channel) {
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setColor('#0099ff')
|
|
||||||
.setTitle('Now Playing')
|
|
||||||
.setDescription(`[${track.title}](${track.uri})`)
|
|
||||||
.addFields({ name: 'Requested by', value: `${track.requester?.tag || 'Unknown'}`, inline: true })
|
|
||||||
.setTimestamp();
|
|
||||||
if (track.thumbnail) {
|
|
||||||
embed.setThumbnail(track.thumbnail);
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.send({ embeds: [embed] }).catch(e => logger.error(`Failed to send trackStart message: ${e.message}`));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
.on('trackEnd', (player, track, payload) => {
|
|
||||||
// Only log track end if it wasn't replaced (e.g., by skip or play next)
|
|
||||||
// 'REPLACED' means another track started immediately after this one.
|
|
||||||
if (payload && payload.reason !== 'REPLACED') {
|
|
||||||
logger.info(`Track ended in guild ${player.guild}: ${track.title}. Reason: ${payload.reason}`);
|
|
||||||
} else if (!payload) {
|
|
||||||
logger.info(`Track ended in guild ${player.guild}: ${track.title}. Reason: Unknown/Finished`);
|
|
||||||
}
|
|
||||||
// Optional: Send a message when a track ends naturally
|
|
||||||
// const channel = client.channels.cache.get(player.textChannel);
|
|
||||||
// if (channel && payload && payload.reason === 'FINISHED') {
|
|
||||||
// channel.send(`Finished playing: ${track.title}`);
|
|
||||||
// }
|
|
||||||
})
|
|
||||||
|
|
||||||
.on('trackError', (player, track, payload) => {
|
|
||||||
logger.error(`Track error in guild ${player.guild} for track ${track?.title || 'Unknown'}: ${payload.error}`);
|
|
||||||
const channel = client.channels.cache.get(player.textChannel);
|
|
||||||
if (channel) {
|
|
||||||
channel.send(`An error occurred while trying to play: ${track?.title || 'the track'}. Details: ${payload.exception?.message || 'Unknown error'}`).catch(e => logger.error(`Failed to send trackError message: ${e.message}`));
|
|
||||||
}
|
|
||||||
// Optionally destroy player or skip track on error
|
|
||||||
// player.stop();
|
|
||||||
})
|
|
||||||
|
|
||||||
.on('trackStuck', (player, track, payload) => {
|
|
||||||
logger.warn(`Track stuck in guild ${player.guild} for track ${track?.title || 'Unknown'}. Threshold: ${payload.thresholdMs}ms`);
|
|
||||||
const channel = client.channels.cache.get(player.textChannel);
|
|
||||||
if (channel) {
|
|
||||||
channel.send(`Track ${track?.title || 'the track'} seems stuck. Skipping...`).catch(e => logger.error(`Failed to send trackStuck message: ${e.message}`));
|
|
||||||
}
|
|
||||||
// Skip the track
|
|
||||||
player.stop();
|
|
||||||
})
|
|
||||||
|
|
||||||
.on('queueEnd', (player) => {
|
|
||||||
logger.info(`Queue ended for guild ${player.guild}.`);
|
|
||||||
const channel = client.channels.cache.get(player.textChannel);
|
|
||||||
if (channel) {
|
|
||||||
channel.send('Queue finished. Add more songs!').catch(e => logger.error(`Failed to send queueEnd message: ${e.message}`));
|
|
||||||
}
|
|
||||||
// Optional: Add a timeout before leaving the channel
|
|
||||||
// setTimeout(() => {
|
|
||||||
// if (player.queue.current) return; // Don't leave if something started playing again
|
|
||||||
// player.destroy();
|
|
||||||
// }, 180000); // 3 minutes
|
|
||||||
player.destroy(); // Destroy player immediately when queue ends
|
|
||||||
})
|
|
||||||
|
|
||||||
.on('playerCreate', player => logger.debug(`Player created for guild ${player.guild}`))
|
|
||||||
.on('playerDestroy', player => logger.debug(`Player destroyed for guild ${player.guild}`))
|
|
||||||
.on('playerMove', (player, oldChannel, newChannel) => {
|
|
||||||
if (!newChannel) {
|
|
||||||
logger.info(`Player for guild ${player.guild} disconnected (moved from channel ${oldChannel}). Destroying player.`);
|
|
||||||
player.destroy();
|
|
||||||
} else {
|
|
||||||
logger.debug(`Player for guild ${player.guild} moved from channel ${oldChannel} to ${newChannel}`);
|
|
||||||
player.setVoiceChannel(newChannel); // Update player's voice channel reference
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("Erela.js event listeners attached.");
|
|
||||||
};
|
|
||||||
@@ -1,281 +0,0 @@
|
|||||||
const logger = require('../utils/logger');
|
|
||||||
const { EmbedBuilder } = require('discord.js');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages player instances and track playback using Shoukaku
|
|
||||||
* @param {Client} client Discord.js client
|
|
||||||
*/
|
|
||||||
class MusicPlayer {
|
|
||||||
constructor(client) {
|
|
||||||
this.client = client;
|
|
||||||
this.players = new Map(); // Store active players
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a player for a guild or returns existing one
|
|
||||||
* @param {Object} options Options for creating the player
|
|
||||||
* @param {string} options.guildId The guild ID
|
|
||||||
* @param {string} options.textChannel The text channel ID
|
|
||||||
* @param {string} options.voiceChannel The voice channel ID
|
|
||||||
* @returns {Object} The player object
|
|
||||||
*/
|
|
||||||
async createPlayer({ guildId, textChannel, voiceChannel }) {
|
|
||||||
// Check if player already exists
|
|
||||||
if (this.players.has(guildId)) {
|
|
||||||
return this.players.get(guildId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Shoukaku node
|
|
||||||
const node = this.client.shoukaku.options.nodeResolver(this.client.shoukaku.nodes);
|
|
||||||
if (!node) {
|
|
||||||
throw new Error('No available Lavalink nodes!');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Create a new connection to the voice channel
|
|
||||||
const connection = await this.client.shoukaku.joinVoiceChannel({
|
|
||||||
guildId: guildId,
|
|
||||||
channelId: voiceChannel,
|
|
||||||
shardId: 0,
|
|
||||||
deaf: true
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a player object to track state and add methods
|
|
||||||
const player = {
|
|
||||||
guild: guildId,
|
|
||||||
textChannel: textChannel,
|
|
||||||
voiceChannel: voiceChannel,
|
|
||||||
connection: connection,
|
|
||||||
queue: [],
|
|
||||||
current: null,
|
|
||||||
playing: false,
|
|
||||||
volume: 100,
|
|
||||||
|
|
||||||
// Play a track
|
|
||||||
async play(track) {
|
|
||||||
this.current = track;
|
|
||||||
|
|
||||||
// Start playback
|
|
||||||
await this.connection.playTrack({ track: track.encoded });
|
|
||||||
this.playing = true;
|
|
||||||
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Stop the current track
|
|
||||||
stop() {
|
|
||||||
this.connection.stopTrack();
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Skip to the next track
|
|
||||||
skip() {
|
|
||||||
this.stop();
|
|
||||||
if (this.queue.length > 0) {
|
|
||||||
const nextTrack = this.queue.shift();
|
|
||||||
this.play(nextTrack);
|
|
||||||
} else {
|
|
||||||
this.current = null;
|
|
||||||
this.playing = false;
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Set player volume
|
|
||||||
setVolume(volume) {
|
|
||||||
this.volume = volume;
|
|
||||||
this.connection.setGlobalVolume(volume);
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Pause playback
|
|
||||||
pause() {
|
|
||||||
this.connection.setPaused(true);
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Resume playback
|
|
||||||
resume() {
|
|
||||||
this.connection.setPaused(false);
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Destroy the player and disconnect
|
|
||||||
destroy() {
|
|
||||||
this.connection.disconnect();
|
|
||||||
musicPlayer.players.delete(this.guild);
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
|
|
||||||
// Add a track to the queue or play it if nothing is playing
|
|
||||||
async enqueue(track, immediate = false) {
|
|
||||||
if (immediate || (!this.playing && !this.current)) {
|
|
||||||
await this.play(track);
|
|
||||||
} else {
|
|
||||||
this.queue.push(track);
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set up event listeners for this player
|
|
||||||
connection.on('start', () => {
|
|
||||||
logger.info(`Track started in guild ${player.guild}: ${player.current?.info?.title || 'Unknown'}`);
|
|
||||||
|
|
||||||
// Send now playing message
|
|
||||||
if (player.current) {
|
|
||||||
const channel = this.client.channels.cache.get(player.textChannel);
|
|
||||||
if (channel) {
|
|
||||||
const embed = new EmbedBuilder()
|
|
||||||
.setColor('#0099ff')
|
|
||||||
.setTitle('Now Playing')
|
|
||||||
.setDescription(`[${player.current.info.title}](${player.current.info.uri})`)
|
|
||||||
.addFields({ name: 'Requested by', value: `${player.current.requester?.tag || 'Unknown'}`, inline: true })
|
|
||||||
.setTimestamp();
|
|
||||||
|
|
||||||
if (player.current.info.thumbnail) {
|
|
||||||
embed.setThumbnail(player.current.info.thumbnail);
|
|
||||||
}
|
|
||||||
|
|
||||||
channel.send({ embeds: [embed] }).catch(e =>
|
|
||||||
logger.error(`Failed to send trackStart message: ${e.message}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
connection.on('end', () => {
|
|
||||||
logger.info(`Track ended in guild ${player.guild}: ${player.current?.info?.title || 'Unknown'}`);
|
|
||||||
player.playing = false;
|
|
||||||
player.current = null;
|
|
||||||
|
|
||||||
// Play next track in queue if available
|
|
||||||
if (player.queue.length > 0) {
|
|
||||||
const nextTrack = player.queue.shift();
|
|
||||||
player.play(nextTrack);
|
|
||||||
} else {
|
|
||||||
// Send queue end message
|
|
||||||
const channel = this.client.channels.cache.get(player.textChannel);
|
|
||||||
if (channel) {
|
|
||||||
channel.send('Queue finished. Add more songs!').catch(e =>
|
|
||||||
logger.error(`Failed to send queueEnd message: ${e.message}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Optional: Add timeout before disconnecting
|
|
||||||
// setTimeout(() => {
|
|
||||||
// if (!player.playing) player.destroy();
|
|
||||||
// }, 300000); // 5 minutes
|
|
||||||
player.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
connection.on('exception', (error) => {
|
|
||||||
logger.error(`Track error in guild ${player.guild}: ${error.message}`);
|
|
||||||
const channel = this.client.channels.cache.get(player.textChannel);
|
|
||||||
if (channel) {
|
|
||||||
channel.send(`An error occurred while trying to play: ${player.current?.info?.title || 'the track'}.
|
|
||||||
Details: ${error.message || 'Unknown error'}`).catch(e =>
|
|
||||||
logger.error(`Failed to send trackError message: ${e.message}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
player.skip();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store the player and return it
|
|
||||||
this.players.set(guildId, player);
|
|
||||||
return player;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to create player for guild ${guildId}: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get an existing player
|
|
||||||
* @param {string} guildId The guild ID
|
|
||||||
* @returns {Object|null} The player object or null
|
|
||||||
*/
|
|
||||||
getPlayer(guildId) {
|
|
||||||
return this.players.get(guildId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search for tracks using Shoukaku
|
|
||||||
* @param {Object} options Options for the search
|
|
||||||
* @param {string} options.query The search query
|
|
||||||
* @param {string} options.requester The user who requested the track
|
|
||||||
* @returns {Promise<Array>} Array of track objects
|
|
||||||
*/
|
|
||||||
async search({ query, requester }) {
|
|
||||||
// Get the first available node
|
|
||||||
const node = this.client.shoukaku.options.nodeResolver(this.client.shoukaku.nodes);
|
|
||||||
if (!node) throw new Error('No available Lavalink nodes!');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Determine search type
|
|
||||||
let searchOptions = {};
|
|
||||||
if (query.startsWith('http')) {
|
|
||||||
// Direct URL
|
|
||||||
searchOptions = { query };
|
|
||||||
} else {
|
|
||||||
// Search with prefix
|
|
||||||
searchOptions = { query: `ytsearch:${query}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform the search
|
|
||||||
const result = await node.rest.resolve(searchOptions);
|
|
||||||
if (!result || result.loadType === 'error' || result.loadType === 'empty') {
|
|
||||||
throw new Error(result?.exception?.message || 'No results found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process results
|
|
||||||
let tracks = [];
|
|
||||||
if (result.loadType === 'playlist') {
|
|
||||||
// Playlist processing
|
|
||||||
tracks = result.data.tracks.map(track => ({
|
|
||||||
track: track.encoded,
|
|
||||||
info: track.info,
|
|
||||||
requester: requester
|
|
||||||
}));
|
|
||||||
} else if (result.loadType === 'track') {
|
|
||||||
// Single track
|
|
||||||
const track = result.data;
|
|
||||||
tracks = [{
|
|
||||||
track: track.encoded,
|
|
||||||
info: track.info,
|
|
||||||
requester: requester
|
|
||||||
}];
|
|
||||||
} else if (result.loadType === 'search') {
|
|
||||||
// Search results
|
|
||||||
tracks = result.data.slice(0, 10).map(track => ({
|
|
||||||
track: track.encoded,
|
|
||||||
info: track.info,
|
|
||||||
requester: requester
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return tracks;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Search error: ${error.message}`);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and export the player manager
|
|
||||||
const musicPlayer = new MusicPlayer(null);
|
|
||||||
module.exports = {
|
|
||||||
setupPlayer: (client) => {
|
|
||||||
if (!client || !client.shoukaku) {
|
|
||||||
logger.error("ShoukakuEvents requires a client with an initialized shoukaku instance.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the player with the client
|
|
||||||
musicPlayer.client = client;
|
|
||||||
|
|
||||||
logger.info("Shoukaku music player initialized and ready.");
|
|
||||||
return musicPlayer;
|
|
||||||
},
|
|
||||||
musicPlayer
|
|
||||||
};
|
|
||||||
66
src/structures/ShoukakuEvents.ts
Normal file
66
src/structures/ShoukakuEvents.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
7
src/types/botClient.ts
Normal file
7
src/types/botClient.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Client, Collection } from "discord.js";
|
||||||
|
import { Shoukaku } from "shoukaku";
|
||||||
|
|
||||||
|
export interface BotClient extends Client {
|
||||||
|
commands: Collection<string, any>;
|
||||||
|
shoukaku: Shoukaku;
|
||||||
|
}
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
const winston = require('winston');
|
|
||||||
|
|
||||||
const logger = winston.createLogger({
|
|
||||||
level: 'info',
|
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
|
||||||
winston.format.printf(info => `${info.timestamp} ${info.level.toUpperCase()}: ${info.message}`)
|
|
||||||
),
|
|
||||||
transports: [
|
|
||||||
new winston.transports.Console(),
|
|
||||||
// Optionally add file transport
|
|
||||||
// new winston.transports.File({ filename: 'combined.log' }),
|
|
||||||
// new winston.transports.File({ filename: 'error.log', level: 'error' }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = logger;
|
|
||||||
33
src/utils/logger.ts
Normal file
33
src/utils/logger.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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,55 +0,0 @@
|
|||||||
jest.mock('discord.js', () => {
|
|
||||||
const original = jest.requireActual('discord.js');
|
|
||||||
const mockRest = {
|
|
||||||
put: jest.fn().mockResolvedValue([{ length: 1 }]),
|
|
||||||
setToken: jest.fn().mockReturnThis(),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...original,
|
|
||||||
REST: jest.fn(() => mockRest),
|
|
||||||
Routes: {
|
|
||||||
applicationCommands: jest.fn().mockReturnValue('/fake-route'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('fs', () => ({
|
|
||||||
readdirSync: jest.fn(() => ['ping.js']),
|
|
||||||
}));
|
|
||||||
jest.mock('node:path', () => {
|
|
||||||
const actual = jest.requireActual('node:path');
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
join: (...args) => args.join('/'),
|
|
||||||
resolve: (...args) => args.join('/'),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('deploy-commands.js', () => {
|
|
||||||
let origEnv;
|
|
||||||
beforeAll(() => {
|
|
||||||
origEnv = { ...process.env };
|
|
||||||
process.env.CLIENT_ID = '12345';
|
|
||||||
process.env.DISCORD_TOKEN = 'token';
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
process.env = origEnv;
|
|
||||||
jest.resetModules();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registers commands via REST API', async () => {
|
|
||||||
const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
|
||||||
jest.mock('../src/utils/logger', () => mockLogger);
|
|
||||||
|
|
||||||
// Run the script
|
|
||||||
await require('../deploy-commands.js');
|
|
||||||
|
|
||||||
const { REST } = require('discord.js');
|
|
||||||
expect(REST).toHaveBeenCalled();
|
|
||||||
const restInstance = REST.mock.results[0].value;
|
|
||||||
expect(restInstance.setToken).toHaveBeenCalledWith('token');
|
|
||||||
expect(restInstance.put).toHaveBeenCalledWith('/fake-route', { body: expect.any(Array) });
|
|
||||||
expect(mockLogger.info).toHaveBeenCalledWith(expect.stringContaining('Started refreshing'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
const { spawnSync } = require('child_process');
|
|
||||||
|
|
||||||
describe('NPM Start Script', () => {
|
|
||||||
test('npm start exits without error when DISCORD_TOKEN is provided', () => {
|
|
||||||
const env = { ...process.env, DISCORD_TOKEN: 'dummy-token', CLIENT_ID: '123', LAVALINK_HOST: 'localhost', LAVALINK_PORT: '2333', LAVALINK_PASSWORD: 'pass' };
|
|
||||||
const result = spawnSync('pnpm', ['start'], { env, encoding: 'utf-8' });
|
|
||||||
// The script starts the bot; if it reaches login attempt, exit code is 0
|
|
||||||
expect(result.status).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
const { spawnSync } = require('child_process');
|
|
||||||
|
|
||||||
describe('Bot Startup', () => {
|
|
||||||
test('exits with code 1 if DISCORD_TOKEN is missing', () => {
|
|
||||||
// Clear DISCORD_TOKEN
|
|
||||||
const env = { ...process.env };
|
|
||||||
delete env.DISCORD_TOKEN;
|
|
||||||
|
|
||||||
const result = spawnSync('node', ['src/index.js'], { env, encoding: 'utf-8' });
|
|
||||||
expect(result.status).toBe(1);
|
|
||||||
expect(result.stderr || result.stdout).toMatch(/DISCORD_TOKEN is missing/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
9
tsconfig.deploy.json
Normal file
9
tsconfig.deploy.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": ".",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext"
|
||||||
|
},
|
||||||
|
"include": ["deploy-commands.ts"]
|
||||||
|
}
|
||||||
26
tsconfig.json
Normal file
26
tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
50
update-plugin.sh
Executable file
50
update-plugin.sh
Executable file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/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
|
||||||
Reference in New Issue
Block a user