Compare commits

...

49 Commits
v0 ... main

Author SHA1 Message Date
aki
a2c9121012 refactor: Enhance voice channel joining logic with retry mechanism and cleanup for existing players 2025-04-25 01:22:43 +08:00
aki
1aa97a8a7a refactor: Update import statements and configurations for ES module compatibility 2025-04-25 00:41:40 +08:00
aki
c613ef3f35 erm what the sigma 2025-04-25 00:23:29 +08:00
aki
9fd3f4a678 erm, fix 2025-04-25 00:20:57 +08:00
aki
a324815788 refactor: Update Dockerfile and TypeScript configurations for improved build process 2025-04-25 00:10:40 +08:00
aki
72a59bbcdd refactor: Refactor interaction handling and event management
- Updated interactionCreate event to improve error handling and logging.
- Enhanced ready event to ensure client user is available before proceeding.
- Refactored voiceStateUpdate event for better clarity and error handling.
- Adjusted index.ts to improve client initialization and command/event loading.
- Improved Shoukaku event handling and initialization in ShoukakuEvents.ts.
- Enhanced logger utility for better message formatting.
- Updated TypeScript configuration for better compatibility and strictness.
- Created a new botClient type definition for improved type safety.
2025-04-24 23:42:36 +08:00
aki
c42e0931d6 test: Remove outdated test files 2025-04-24 16:09:49 +08:00
aki
228d0bef69 feat: Add YouTube OAuth Token to environment configuration and update README 2025-04-24 16:09:28 +08:00
aki
3c4dc51855 refactor: Convert project from JavaScript to TypeScript
- Converted all .js files to .ts
- Added TypeScript configuration (tsconfig.json)
- Added ESLint and Prettier configuration
- Updated package.json dependencies
- Modified Docker and application configurations
2025-04-24 13:48:10 +08:00
aki
75185a59c3 last ditch effort :c 2025-04-24 03:58:01 +08:00
aki
5b51e3f529 feat(youtube-plugin): Grab the latest snapshot from Maven latest releases 2025-04-24 03:57:41 +08:00
aki
99a1417c43 debug :> 2025-04-24 03:33:44 +08:00
aki
6546cb8d63 if wsl wasn't so broken, i didn't need to commit this much :)
fix(ShoukakuEvents.js): ensure playback track payload structure matches expected format
2025-04-24 03:15:04 +08:00
aki
b958e79a98 fix(ShoukakuEvents.js): correct property name for track encoding in search results
hopefully the final fix :)
2025-04-24 03:12:01 +08:00
aki
81c65a3644 chore(ShoukakuEvents.js): add logging for track encoded data during playback 2025-04-24 03:07:08 +08:00
aki
68a3f4fb58 chore: update .env.example and logger.js for improved configuration and logging level management 2025-04-24 03:03:05 +08:00
aki
5c5574c06e >,> 2025-04-24 02:55:39 +08:00
aki
9e02e50693 fix(ShoukakuEvents.js): improve error handling during track playback and logging 2025-04-24 02:50:17 +08:00
aki
9d7ff5e7e7 feat(play.js): add source option for track search and enhance search logic 2025-04-24 02:50:11 +08:00
aki
3ba230e6e9 chore: update Dockerfile to use Node.js 23-slim and adjust application.yml for plugin settings 2025-04-24 02:49:36 +08:00
aki
253f369a89 refactor(application.yml): streamline Lavalink configuration and enhance plugin management 2025-04-24 02:23:15 +08:00
aki
7500ea01c8 untest the testing earlier 2025-04-24 02:06:06 +08:00
aki
f31bba40fb add youtube plugin 2025-04-24 02:01:39 +08:00
aki
4d5c301c46 <,< 2025-04-24 01:59:13 +08:00
aki
8f8ff6aa81 fix(lavalink): Update Lavalink configuration and enhance healthcheck with plugin management 2025-04-24 01:50:06 +08:00
aki
ca9e531541 pause fix again hope -o- 2025-04-24 01:40:14 +08:00
aki
f1991f7716 testing 2025-04-24 01:33:42 +08:00
aki
ce635cb32b sleepy 2025-04-24 01:29:28 +08:00
aki
30b5b23868 fix(docker-compose): Update healthcheck for Lavalink service to use correct endpoint and reduce interval 2025-04-24 01:27:17 +08:00
aki
74cac2bfbb fix(lavalink): Refactor search method to use identifier string for track resolution 2025-04-24 01:22:19 +08:00
aki
bb7a796cf9 fix(docker-compose): Add healthcheck for Lavalink service and update depends_on condition 2025-04-24 01:22:09 +08:00
aki
a54becb3a0 fix(lavalink): Update leave for Shoukaku integration 2025-04-24 01:21:44 +08:00
aki
0d0125bf55 fix(lavalink): Update join, play, and voice state handling for Shoukaku integration 2025-04-24 01:01:33 +08:00
aki
854cf12d64 fix(lavalink): Correct Lavalink connection URL format and update deprecated code 2025-04-24 00:52:16 +08:00
aki
e54c23cc63 feat(lavalink): Migrate from Erela.js to Shoukaku for music playback management 2025-04-24 00:25:02 +08:00
aki
5a29fe3d9d chore(lavalink): another application.yml fix cuz i'm sleepy 2025-04-24 00:19:05 +08:00
aki
42de01e004 chore(lavalink): Add YouTube plugin in application.yml to fix deprecation error 2025-04-24 00:14:42 +08:00
aki
537a8c6709 fix(docker): Update environment variables and improve connection handling for Lavalink 2025-04-24 00:05:57 +08:00
Aki Amane
0b86b5d891 fix(docker): Fix Dockerfile errors 2025-04-23 23:55:29 +08:00
aki
d4de2feaaa chore(docker): Add application.yml for lavaplayer 2025-04-23 23:28:48 +08:00
aki
95ea55d972 chore(docker): switch to pnpm, update Dockerfile, docker-compose, and README 2025-04-23 23:27:29 +08:00
aki
170faf7d01 fix(package): format scripts section and add js-yaml to devDependencies 2025-04-23 22:09:35 +08:00
aki
f50c88515e test(scripts): add npm start script test 2025-04-23 22:09:00 +08:00
aki
57d10ddf70 test(deploy): add tests for deploy-commands script 2025-04-23 22:05:13 +08:00
aki
6daf1993d1 build(docker): add Dockerfile, Docker Compose config, and update README 2025-04-23 21:55:40 +08:00
aki
47de3823f3 test(startup): add startup test for missing DISCORD_TOKEN 2025-04-23 21:54:52 +08:00
aki
74dfdbf667 feat(bot): add NodeJS implementation and deploy script 2025-04-23 21:40:02 +08:00
aki
5c632556b7 chore(rust): remove Rust source files and Cargo configuration 2025-04-23 21:39:06 +08:00
aki
05fec6747d Initial project setup with basic structure, including environment configuration, command handling, and Lavalink integration. 2025-04-20 09:02:34 +08:00
27 changed files with 1901 additions and 20 deletions

21
.env.example Normal file
View File

@ -0,0 +1,21 @@
# Discord Bot Token
DISCORD_TOKEN=your_token_here
# 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_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

37
.gitignore vendored
View File

@ -1,22 +1,23 @@
# ---> Rust
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Node modules
node_modules/
dist/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# dotenv environment variables
.env
# These are backup files generated by rustfmt
**/*.rs.bk
# VSCode settings
.vscode/
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Mac system files
.DS_Store
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Lockfiles
pnpm-lock.yaml
# Logs
npm-debug.log*
logs/
*.log
# Data directory
data/

5
.prettierignore Normal file
View File

@ -0,0 +1,5 @@
dist
node_modules
coverage
build
*.d.ts

10
.prettierrc.json Normal file
View File

@ -0,0 +1,10 @@
{
"singleQuote": false,
"trailingComma": "all",
"semi": true,
"printWidth": 100,
"tabWidth": 2,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

43
Dockerfile Normal file
View File

@ -0,0 +1,43 @@
# ---- Build Stage ----
FROM node:23-alpine AS builder
WORKDIR /app
# Install pnpm and necessary build tools
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 ./
# Now copy source code
COPY src/ ./src/
COPY deploy-commands.ts ./
# Install dependencies AFTER copying config files
RUN pnpm install --frozen-lockfile
# Build the TypeScript code directly
RUN npx tsc -p tsconfig.json && npx tsc -p tsconfig.deploy.json
# ---- Production Stage ----
FROM node:23-alpine
WORKDIR /app
ENV NODE_ENV=production
# 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"]

168
README.md
View File

@ -1,3 +1,167 @@
# discord-music-bot
# Discord Music Bot (TypeScript)
Discord bot made in Rust using serenity and lavalink-rs
Discord music bot template written in TypeScript using `discord.js` and `shoukaku`, with Lavalink support.
## Features
- Slash commands (e.g., `/ping`, `/join`, `/play`, `/leave`)
- `shoukaku` integration for robust Lavalink audio playback
- Modular command and event handlers written in TypeScript
- Basic Docker support (`Dockerfile`, `docker-compose.yml`)
- Comprehensive test suite with Jest
## Prerequisites
- Node.js (>=16 recommended, check `package.json` for specific engine requirements)
- pnpm (recommended) or npm
- TypeScript (`typescript` package, usually installed as a dev dependency)
- A Discord application with bot token and client ID
- A running Lavalink server
## Setup
1. **Clone the repository:**
```sh
git clone <repository_url>
cd discord-music-bot
```
2. **Install dependencies:**
```sh
pnpm install
```
3. **Configure Environment:**
Copy `.env.example` to `.env` and fill in your credentials:
```dotenv
# Discord Bot Token (Required)
DISCORD_TOKEN=your_discord_bot_token
# Discord Application Client ID (Required for command deployment)
CLIENT_ID=your_discord_application_id
# Discord Guild ID (Optional, for deploying commands to a specific test server)
# GUILD_ID=your_guild_id_here
# Lavalink Configuration (Required)
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
A `Dockerfile` and `docker-compose.yml` are provided for containerized deployment.
- Ensure your `.env` file is configured correctly.
- Build and run with Docker Compose:
```sh
docker-compose up --build -d # Use -d to run in detached mode
```
- The `docker-compose.yml` includes both the bot service and a Lavalink service.
## Project Structure
```
.
├── src/ # Source code directory
│ ├── commands/ # Slash command modules (.ts)
│ ├── events/ # Discord.js and Shoukaku event handlers (.ts)
│ ├── structures/ # Custom structures or base classes (e.g., Shoukaku event handlers)
│ └── utils/ # Utility functions (e.g., logger.ts)
├── 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
This project is licensed under the **GNU General Public License v3.0**. See the [LICENSE](LICENSE) file for details.

116
application.yml Normal file
View File

@ -0,0 +1,116 @@
server: # REST and WS server
port: 2333
address: 0.0.0.0
http2:
enabled: false # Whether to enable HTTP/2 support
# Root level plugin configuration block
plugins:
youtube:
enabled: true # Whether this source can be used.
allowSearch: true # Whether "ytsearch:" and "ytmsearch:" can be used.
allowDirectVideoIds: true # Whether just video IDs can match. If false, only complete URLs will be loaded.
allowDirectPlaylistIds: true # Whether just playlist IDs can match. If false, only complete URLs will be loaded.
# The clients to use for track loading. See below for a list of valid clients.
# Clients are queried in the order they are given (so the first client is queried first and so on...)
clients:
- WEB
- WEBEMBEDDED
- MUSIC
oauth:
enabled: true
# If you obtain a refresh token after the initial OAuth flow, you can add it here
# refreshToken: "paste your refresh token here if applicable"
# Leave skipInitialization commented for first-time setup
# skipInitialization: true
lavalink:
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

108
deploy-commands.ts Normal file
View 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.

46
docker-compose.yml Normal file
View File

@ -0,0 +1,46 @@
services:
lavalink:
image: fredboat/lavalink:latest
container_name: lavalink
restart: unless-stopped
networks:
- bot-network
ports:
- "2333:2333"
environment:
- LAVALINK_SERVER_PASSWORD=${LAVALINK_PASSWORD}
# Removed LAVALINK_PLUGIN_URLS environment variable
volumes:
- ./application.yml:/opt/Lavalink/application.yml:ro,Z
# Mount local plugins directory into the container with SELinux label
- ./plugins:/plugins:ro,Z
# Add healthcheck to verify Lavalink is ready
healthcheck:
# Use CMD-SHELL to allow environment variable expansion for the password
test: ["CMD-SHELL", "curl -H \"Authorization: $$LAVALINK_SERVER_PASSWORD\" -f http://localhost:2333/version || exit 1"]
interval: 10s # Increased interval slightly
timeout: 5s
retries: 5
start_period: 15s # Give Lavalink time to start up initially
# Removed command override, will use default image entrypoint
bot:
build: .
container_name: discord-music-bot
restart: unless-stopped
networks:
- bot-network
env_file:
- .env
environment:
LAVALINK_HOST: lavalink
LAVALINK_PORT: 2333
LAVALINK_PASSWORD: ${LAVALINK_PASSWORD}
# Update depends_on to wait for healthcheck
depends_on:
lavalink:
condition: service_healthy
networks:
bot-network:
driver: bridge

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "discord-music-bot",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"build:deploy": "tsc -p tsconfig.deploy.json",
"build:all": "npm run build && npm run build:deploy",
"start": "node dist/index.js",
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
"lint": "eslint .",
"format": "prettier --write src/**/*.ts deploy-commands.ts",
"prepare": "npm run build:all"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"discord.js": "^14.18.0",
"dotenv": "^16.5.0",
"shoukaku": "^4.1.1",
"winston": "^3.17.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.14.1",
"js-yaml": "^4.1.0",
"npm": "^11.3.0",
"prettier": "^3.5.3",
"ts-node-dev": "^2.0.0",
"typescript": "^5.8.3"
}
}

60
scripts/fix-imports.cjs Executable file
View 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
View 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();

127
src/commands/join.ts Normal file
View 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.`);
}
}
}
},
};

81
src/commands/leave.ts Normal file
View 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}`);
});
}
},
};

22
src/commands/ping.ts Normal file
View 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`,
);
},
};

445
src/commands/play.ts Normal file
View 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(() => {});
// }
}
},
};

View 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,
});
}
}
},
};

34
src/events/ready.ts Normal file
View 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 });
},
};

View 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
}
}
},
};

200
src/index.ts Normal file
View 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 });
});

View 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
View 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;
}

33
src/utils/logger.ts Normal file
View 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

9
tsconfig.deploy.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": ["deploy-commands.ts"]
}

26
tsconfig.json Normal file
View 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
View 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