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.
This commit is contained in:
parent
c42e0931d6
commit
72a59bbcdd
@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"root": true,
|
|
||||||
"parser": "@typescript-eslint/parser",
|
|
||||||
"parserOptions": {
|
|
||||||
"ecmaVersion": "latest",
|
|
||||||
"sourceType": "module",
|
|
||||||
"project": "./tsconfig.json" // Point ESLint to your TS config
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
"@typescript-eslint",
|
|
||||||
"prettier" // Integrates Prettier rules into ESLint
|
|
||||||
],
|
|
||||||
"extends": [
|
|
||||||
"eslint:recommended",
|
|
||||||
"plugin:@typescript-eslint/recommended", // Recommended TS rules
|
|
||||||
"plugin:@typescript-eslint/recommended-requiring-type-checking", // Rules requiring type info
|
|
||||||
"plugin:prettier/recommended" // Enables eslint-plugin-prettier and eslint-config-prettier
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
// Add or override specific rules here if needed
|
|
||||||
"prettier/prettier": "warn", // Show Prettier issues as warnings
|
|
||||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], // Warn about unused vars, allow underscores
|
|
||||||
"@typescript-eslint/explicit-module-boundary-types": "off", // Allow inferred return types for now
|
|
||||||
"@typescript-eslint/no-explicit-any": "warn" // Warn about using 'any'
|
|
||||||
},
|
|
||||||
"ignorePatterns": ["node_modules/", "dist/", "data/", "*.db", "*.db-journal", "*.db-wal"]
|
|
||||||
}
|
|
||||||
@ -1,21 +1,5 @@
|
|||||||
# Ignore artifacts:
|
|
||||||
node_modules
|
|
||||||
dist
|
dist
|
||||||
|
node_modules
|
||||||
coverage
|
coverage
|
||||||
data
|
build
|
||||||
*.db
|
*.d.ts
|
||||||
*.db-journal
|
|
||||||
*.db-wal
|
|
||||||
|
|
||||||
# Ignore configuration files managed by other tools:
|
|
||||||
package-lock.json
|
|
||||||
pnpm-lock.yaml
|
|
||||||
yarn.lock
|
|
||||||
|
|
||||||
# Ignore logs:
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# Ignore environment files:
|
|
||||||
.env*
|
|
||||||
!.env.example
|
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
{
|
{
|
||||||
"semi": true,
|
|
||||||
"trailingComma": "es5",
|
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"printWidth": 80,
|
"trailingComma": "all",
|
||||||
|
"semi": true,
|
||||||
|
"printWidth": 100,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"useTabs": false,
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
"endOfLine": "lf"
|
"endOfLine": "lf"
|
||||||
}
|
}
|
||||||
|
|||||||
76
README.md
76
README.md
@ -8,6 +8,7 @@ Discord music bot template written in TypeScript using `discord.js` and `shoukak
|
|||||||
- `shoukaku` integration for robust Lavalink audio playback
|
- `shoukaku` integration for robust Lavalink audio playback
|
||||||
- Modular command and event handlers written in TypeScript
|
- Modular command and event handlers written in TypeScript
|
||||||
- Basic Docker support (`Dockerfile`, `docker-compose.yml`)
|
- Basic Docker support (`Dockerfile`, `docker-compose.yml`)
|
||||||
|
- Comprehensive test suite with Jest
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@ -83,6 +84,51 @@ Discord music bot template written in TypeScript using `discord.js` and `shoukak
|
|||||||
pnpm start # Check package.json for the exact start script (might run compiled JS or use ts-node)
|
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.
|
||||||
@ -100,22 +146,20 @@ A `Dockerfile` and `docker-compose.yml` are provided for containerized deploymen
|
|||||||
.
|
.
|
||||||
├── src/ # Source code directory
|
├── src/ # Source code directory
|
||||||
│ ├── commands/ # Slash command modules (.ts)
|
│ ├── commands/ # Slash command modules (.ts)
|
||||||
│ ├── events/ # Discord.js and Shoukaku event handlers (.ts)
|
│ ├── events/ # Discord.js and Shoukaku event handlers (.ts)
|
||||||
│ ├── structures/ # Custom structures or base classes (e.g., Shoukaku event handlers)
|
│ ├── structures/ # Custom structures or base classes (e.g., Shoukaku event handlers)
|
||||||
│ ├── utils/ # Utility functions (e.g., logger.ts)
|
│ └── utils/ # Utility functions (e.g., logger.ts)
|
||||||
│ └── index.ts # Main application entry point
|
├── tests/ # Test files (see Testing section)
|
||||||
├── plugins/ # Lavalink plugins (e.g., youtube-plugin-*.jar)
|
├── plugins/ # Lavalink plugins (e.g., youtube-plugin-*.jar)
|
||||||
├── tests/ # Test files
|
├── .env.example # Example environment variables
|
||||||
├── .env.example # Example environment variables
|
├── application.yml # Lavalink server configuration
|
||||||
├── .gitignore
|
├── deploy-commands.ts # Script to register slash commands
|
||||||
├── application.yml # Lavalink server configuration
|
├── docker-compose.yml # Docker Compose configuration
|
||||||
├── deploy-commands.ts # Script to register slash commands
|
├── Dockerfile # Dockerfile for building the bot image
|
||||||
├── docker-compose.yml # Docker Compose configuration for bot + Lavalink
|
├── jest.config.ts # Jest test configuration
|
||||||
├── Dockerfile # Dockerfile for building the bot image
|
├── package.json # Node.js project manifest
|
||||||
├── LICENSE # Project License (GPLv3)
|
├── tsconfig.json # TypeScript compiler options
|
||||||
├── package.json # Node.js project manifest
|
└── update-plugin.sh # Script to update Lavalink plugins
|
||||||
├── tsconfig.json # TypeScript compiler options
|
|
||||||
└── update-plugin.sh # Script to update Lavalink plugins (example)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { REST, Routes, APIApplicationCommand } from 'discord.js';
|
import { REST, Routes, APIApplicationCommand } from "discord.js";
|
||||||
import fs from 'node:fs';
|
import fs from "node:fs";
|
||||||
import path from 'node:path';
|
import path from "node:path";
|
||||||
import logger from './src/utils/logger'; // Use default import now
|
import logger from "./src/utils/logger"; // Use default import now
|
||||||
import dotenv from 'dotenv';
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
// --- Setup ---
|
// --- Setup ---
|
||||||
dotenv.config(); // Load .env variables
|
dotenv.config(); // Load .env variables
|
||||||
@ -16,83 +16,87 @@ const clientId = process.env.CLIENT_ID;
|
|||||||
const token = process.env.DISCORD_TOKEN;
|
const token = process.env.DISCORD_TOKEN;
|
||||||
|
|
||||||
if (!clientId || !token) {
|
if (!clientId || !token) {
|
||||||
logger.error('Missing CLIENT_ID or DISCORD_TOKEN in .env file for command deployment!');
|
logger.error("Missing CLIENT_ID or DISCORD_TOKEN in .env file for command deployment!");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const commands: Omit<APIApplicationCommand, 'id' | 'application_id' | 'version'>[] = []; // Type the commands array more accurately
|
const commands: Omit<APIApplicationCommand, "id" | "application_id" | "version">[] = []; // Type the commands array more accurately
|
||||||
// Grab all the command files from the commands directory
|
// Grab all the command files from the commands directory
|
||||||
const commandsPath = path.join(__dirname, 'src', 'commands');
|
const commandsPath = path.join(__dirname, "src", "commands");
|
||||||
// Read .ts files now
|
// Read .ts files now
|
||||||
const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith('.ts')); // Add string type
|
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
|
// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
|
||||||
logger.info(`Started loading ${commandFiles.length} application (/) commands for deployment.`);
|
logger.info(`Started loading ${commandFiles.length} application (/) commands for deployment.`);
|
||||||
|
|
||||||
const loadCommandsForDeployment = async () => {
|
const loadCommandsForDeployment = async () => {
|
||||||
for (const file of commandFiles) {
|
for (const file of commandFiles) {
|
||||||
const filePath = path.join(commandsPath, file);
|
const filePath = path.join(commandsPath, file);
|
||||||
try {
|
try {
|
||||||
// Use dynamic import
|
// Use dynamic import
|
||||||
const commandModule = await import(filePath);
|
const commandModule = await import(filePath);
|
||||||
// Assuming commands export default or have a 'default' property
|
// Assuming commands export default or have a 'default' property
|
||||||
const command = commandModule.default || commandModule;
|
const command = commandModule.default || commandModule;
|
||||||
|
|
||||||
if (command && typeof command === 'object' && 'data' in command && typeof command.data.toJSON === 'function') {
|
if (
|
||||||
// We push the JSON representation which matches the API structure
|
command &&
|
||||||
commands.push(command.data.toJSON());
|
typeof command === "object" &&
|
||||||
logger.info(`Loaded command for deployment: ${command.data.name}`);
|
"data" in command &&
|
||||||
} else {
|
typeof command.data.toJSON === "function"
|
||||||
logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" property with a "toJSON" method.`);
|
) {
|
||||||
}
|
// We push the JSON representation which matches the API structure
|
||||||
} catch (error: unknown) { // Type error as unknown
|
commands.push(command.data.toJSON());
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
logger.info(`Loaded command for deployment: ${command.data.name}`);
|
||||||
logger.error(`Error loading command at ${filePath} for deployment: ${errorMessage}`, error);
|
} 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
|
// Construct and prepare an instance of the REST module
|
||||||
const rest = new REST({ version: '10' }).setToken(token);
|
const rest = new REST({ version: "10" }).setToken(token);
|
||||||
|
|
||||||
// Define the deployment function
|
// Define the deployment function
|
||||||
const deployCommands = async () => {
|
const deployCommands = async () => {
|
||||||
try {
|
try {
|
||||||
await loadCommandsForDeployment(); // Wait for commands to be loaded
|
await loadCommandsForDeployment(); // Wait for commands to be loaded
|
||||||
|
|
||||||
if (commands.length === 0) {
|
if (commands.length === 0) {
|
||||||
logger.warn('No commands loaded for deployment. Exiting.');
|
logger.warn("No commands loaded for deployment. Exiting.");
|
||||||
return;
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Execute the deployment
|
||||||
|
|||||||
18
package.json
18
package.json
@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "discord-music-bot",
|
"name": "discord-music-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
"lint": "eslint src/**/*.ts tests/**/*.ts deploy-commands.ts",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write src/**/*.ts tests/**/*.ts deploy-commands.ts",
|
"format": "prettier --write src/**/*.ts deploy-commands.ts",
|
||||||
"test": "jest"
|
"prepare": "npm run build"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
@ -21,16 +22,11 @@
|
|||||||
"winston": "^3.17.0"
|
"winston": "^3.17.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.14.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.31.0",
|
|
||||||
"@typescript-eslint/parser": "^8.31.0",
|
|
||||||
"eslint": "^9.25.1",
|
|
||||||
"eslint-config-prettier": "^10.1.2",
|
|
||||||
"eslint-plugin-prettier": "^5.2.6",
|
|
||||||
"jest": "^29.7.0",
|
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
|
"npm": "^11.3.0",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"ts-node-dev": "^2.0.0",
|
"ts-node-dev": "^2.0.0",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
|||||||
@ -1,122 +1,143 @@
|
|||||||
import {
|
import {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
ChannelType,
|
ChannelType,
|
||||||
ChatInputCommandInteraction, // Import the specific interaction type
|
ChatInputCommandInteraction, // Import the specific _interaction type
|
||||||
GuildMember, // Import GuildMember type
|
GuildMember, // Import GuildMember type
|
||||||
VoiceBasedChannel // Import VoiceBasedChannel type
|
VoiceBasedChannel, // Import VoiceBasedChannel type
|
||||||
} from 'discord.js';
|
} from "discord.js";
|
||||||
import logger from '../utils/logger'; // Use default import
|
import logger from "../utils/logger"; // Use default import
|
||||||
import { BotClient } from '../index'; // Import the BotClient interface
|
import { BotClient } from "../index"; // Import the BotClient interface
|
||||||
import { Player } from 'shoukaku'; // Import the Player type explicitly
|
import { Player } from "shoukaku"; // Import the Player type explicitly
|
||||||
|
|
||||||
export default { // Use export default for ES Modules
|
export default {
|
||||||
data: new SlashCommandBuilder()
|
// Use export default for ES Modules
|
||||||
.setName('join')
|
data: new SlashCommandBuilder()
|
||||||
.setDescription('Joins your current voice channel'),
|
.setName("join")
|
||||||
async execute(interaction: ChatInputCommandInteraction, client: BotClient) { // Add types
|
.setDescription("Joins your current voice channel"),
|
||||||
// Ensure command is run in a guild
|
async execute(_interaction: ChatInputCommandInteraction, _client: BotClient) {
|
||||||
if (!interaction.guildId || !interaction.guild || !interaction.channelId) {
|
// Add types
|
||||||
// Reply might fail if interaction is already replied/deferred, use editReply if needed
|
// Ensure command is run in a guild
|
||||||
return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }).catch(() => {});
|
if (!_interaction.guildId || !_interaction.guild || !_interaction.channelId) {
|
||||||
|
// Reply might fail if _interaction is already replied/deferred, use editReply if needed
|
||||||
|
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!); // Use non-null assertion for _client.user
|
||||||
|
if (!permissions?.has(PermissionFlagsBits.Connect)) {
|
||||||
|
// Optional chaining for permissions
|
||||||
|
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
|
||||||
|
// Correctly get player from the players map and type it
|
||||||
|
let player: Player | undefined = shoukaku.players.get(_interaction.guildId);
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
try {
|
||||||
|
// Create player using the Shoukaku manager
|
||||||
|
player = await shoukaku.joinVoiceChannel({
|
||||||
|
guildId: _interaction.guildId,
|
||||||
|
channelId: currentVoiceChannel.id,
|
||||||
|
shardId: _interaction.guild.shardId, // Get shardId from guild
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Created player and connected to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${_interaction.guild.name} (${_interaction.guildId})`,
|
||||||
|
);
|
||||||
|
await _interaction.editReply(`Joined ${currentVoiceChannel.name}! Ready to play music.`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Type error as unknown
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(
|
||||||
|
`Failed to create/connect player for guild ${_interaction.guildId}: ${errorMessage}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
// Attempt to leave voice channel if connection failed partially
|
||||||
|
shoukaku.leaveVoiceChannel(_interaction.guildId).catch((e: unknown) => {
|
||||||
|
// Type catch error
|
||||||
|
const leaveErrorMsg = e instanceof Error ? e.message : String(e);
|
||||||
|
logger.error(`Error leaving VC after failed join: ${leaveErrorMsg}`);
|
||||||
|
});
|
||||||
|
return _interaction.editReply("An error occurred while trying to join the voice channel.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If player exists, get the corresponding connection
|
||||||
|
const connection = shoukaku.connections.get(_interaction.guildId);
|
||||||
|
|
||||||
|
// Check if connection exists and if it's in a different channel
|
||||||
|
if (!connection || connection.channelId !== currentVoiceChannel.id) {
|
||||||
|
try {
|
||||||
|
// Rejoining should handle moving the bot
|
||||||
|
// Note: joinVoiceChannel might implicitly destroy the old player/connection if one exists for the guild.
|
||||||
|
// If issues arise, explicitly call leaveVoiceChannel first.
|
||||||
|
player = await shoukaku.joinVoiceChannel({
|
||||||
|
guildId: _interaction.guildId,
|
||||||
|
channelId: currentVoiceChannel.id,
|
||||||
|
shardId: _interaction.guild.shardId,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Moved player to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${_interaction.guildId}`,
|
||||||
|
);
|
||||||
|
await _interaction.editReply(`Moved to ${currentVoiceChannel.name}!`);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Type error as unknown
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(
|
||||||
|
`Failed to move player for guild ${_interaction.guildId}: ${errorMessage}`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
return _interaction.editReply(
|
||||||
|
"An error occurred while trying to move to the voice channel.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Ensure interaction.member is a GuildMember
|
} else {
|
||||||
if (!(interaction.member instanceof GuildMember)) {
|
// Already in the correct channel
|
||||||
return interaction.reply({ content: 'Could not determine your voice channel.', ephemeral: true }).catch(() => {});
|
await _interaction.editReply(`I'm already in ${currentVoiceChannel.name}!`);
|
||||||
}
|
}
|
||||||
|
// Example of updating a manually managed text channel context (if needed)
|
||||||
// Use ephemeral deferral
|
// if (player.textChannelId !== _interaction.channelId) {
|
||||||
await interaction.deferReply({ ephemeral: true });
|
// player.textChannelId = _interaction.channelId;
|
||||||
|
// logger.debug(`Updated player text channel context to ${_interaction.channel?.name} (${_interaction.channelId}) in guild ${_interaction.guildId}`);
|
||||||
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!); // Use non-null assertion for client.user
|
|
||||||
if (!permissions?.has(PermissionFlagsBits.Connect)) { // Optional chaining for permissions
|
|
||||||
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
|
|
||||||
// Correctly get player from the players map and type it
|
|
||||||
let player: Player | undefined = shoukaku.players.get(interaction.guildId);
|
|
||||||
|
|
||||||
if (!player) {
|
|
||||||
try {
|
|
||||||
// Create player using the Shoukaku manager
|
|
||||||
player = await shoukaku.joinVoiceChannel({
|
|
||||||
guildId: interaction.guildId,
|
|
||||||
channelId: currentVoiceChannel.id,
|
|
||||||
shardId: interaction.guild.shardId, // Get shardId from guild
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Created player and connected to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${interaction.guild.name} (${interaction.guildId})`);
|
|
||||||
await interaction.editReply(`Joined ${currentVoiceChannel.name}! Ready to play music.`);
|
|
||||||
|
|
||||||
} catch (error: unknown) { // Type error as unknown
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(`Failed to create/connect player for guild ${interaction.guildId}: ${errorMessage}`, error);
|
|
||||||
// Attempt to leave voice channel if connection failed partially
|
|
||||||
shoukaku.leaveVoiceChannel(interaction.guildId).catch((e: unknown) => { // Type catch error
|
|
||||||
const leaveErrorMsg = e instanceof Error ? e.message : String(e);
|
|
||||||
logger.error(`Error leaving VC after failed join: ${leaveErrorMsg}`);
|
|
||||||
});
|
|
||||||
return interaction.editReply('An error occurred while trying to join the voice channel.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If player exists, get the corresponding connection
|
|
||||||
const connection = shoukaku.connections.get(interaction.guildId);
|
|
||||||
|
|
||||||
// Check if connection exists and if it's in a different channel
|
|
||||||
if (!connection || connection.channelId !== currentVoiceChannel.id) {
|
|
||||||
try {
|
|
||||||
// Rejoining should handle moving the bot
|
|
||||||
// Note: joinVoiceChannel might implicitly destroy the old player/connection if one exists for the guild.
|
|
||||||
// If issues arise, explicitly call leaveVoiceChannel first.
|
|
||||||
player = await shoukaku.joinVoiceChannel({
|
|
||||||
guildId: interaction.guildId,
|
|
||||||
channelId: currentVoiceChannel.id,
|
|
||||||
shardId: interaction.guild.shardId,
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Moved player to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${interaction.guildId}`);
|
|
||||||
await interaction.editReply(`Moved to ${currentVoiceChannel.name}!`);
|
|
||||||
} catch (error: unknown) { // Type error as unknown
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
||||||
logger.error(`Failed to move player for guild ${interaction.guildId}: ${errorMessage}`, 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 ${currentVoiceChannel.name}!`);
|
|
||||||
}
|
|
||||||
// Example of updating a manually managed text channel context (if needed)
|
|
||||||
// if (player.textChannelId !== interaction.channelId) {
|
|
||||||
// player.textChannelId = interaction.channelId;
|
|
||||||
// logger.debug(`Updated player text channel context to ${interaction.channel?.name} (${interaction.channelId}) in guild ${interaction.guildId}`);
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,67 +1,81 @@
|
|||||||
import {
|
import {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
ChatInputCommandInteraction, // Import the specific interaction type
|
ChatInputCommandInteraction, // Import the specific _interaction type
|
||||||
GuildMember // Import GuildMember type
|
GuildMember, // Import GuildMember type
|
||||||
} from 'discord.js';
|
} from "discord.js";
|
||||||
import logger from '../utils/logger'; // Use default import
|
import logger from "../utils/logger"; // Use default import
|
||||||
import { BotClient } from '../index'; // Import the BotClient interface
|
import { BotClient } from "../index"; // Import the BotClient interface
|
||||||
// No need to import Player explicitly if we just check connection
|
// No need to import Player explicitly if we just check connection
|
||||||
|
|
||||||
export default { // Use export default for ES Modules
|
export default {
|
||||||
data: new SlashCommandBuilder()
|
// Use export default for ES Modules
|
||||||
.setName('leave')
|
data: new SlashCommandBuilder()
|
||||||
.setDescription('Leaves the current voice channel'),
|
.setName("leave")
|
||||||
async execute(interaction: ChatInputCommandInteraction, client: BotClient) { // Add types
|
.setDescription("Leaves the current voice channel"),
|
||||||
// Ensure command is run in a guild
|
async execute(_interaction: ChatInputCommandInteraction, _client: BotClient) {
|
||||||
if (!interaction.guildId || !interaction.guild) {
|
// Add types
|
||||||
return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }).catch(() => {});
|
// Ensure command is run in a guild
|
||||||
}
|
if (!_interaction.guildId || !_interaction.guild) {
|
||||||
// Ensure interaction.member is a GuildMember (optional, but good practice)
|
return _interaction
|
||||||
if (!(interaction.member instanceof GuildMember)) {
|
.reply({ content: "This command can only be used in a server.", ephemeral: true })
|
||||||
return interaction.reply({ content: 'Could not verify your membership.', ephemeral: true }).catch(() => {});
|
.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
|
// Use ephemeral deferral
|
||||||
await interaction.deferReply({ ephemeral: true });
|
await _interaction.deferReply({ ephemeral: true });
|
||||||
|
|
||||||
// Get the Shoukaku instance
|
// Get the Shoukaku instance
|
||||||
const shoukaku = client.shoukaku;
|
const shoukaku = _client.shoukaku;
|
||||||
if (!shoukaku) {
|
if (!shoukaku) {
|
||||||
logger.error('Shoukaku instance not found on client object!');
|
logger.error("Shoukaku instance not found on _client object!");
|
||||||
return interaction.editReply('The music player is not ready yet.');
|
return _interaction.editReply("The music player is not ready yet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a connection exists for this guild
|
// Check if a connection exists for this guild
|
||||||
const connection = shoukaku.connections.get(interaction.guildId);
|
const connection = shoukaku.connections.get(_interaction.guildId);
|
||||||
if (!connection || !connection.channelId) {
|
if (!connection || !connection.channelId) {
|
||||||
return interaction.editReply('I am not currently in a voice channel!');
|
return _interaction.editReply("I am not currently in a voice channel!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional: Check if the user is in the same channel as the bot
|
// Optional: Check if the user is in the same channel as the bot
|
||||||
// const memberVoiceChannelId = interaction.member.voice.channelId;
|
// const memberVoiceChannelId = _interaction.member.voice.channelId;
|
||||||
// if (memberVoiceChannelId !== connection.channelId) {
|
// if (memberVoiceChannelId !== connection.channelId) {
|
||||||
// return interaction.editReply('You need to be in the same voice channel as me to make me leave!');
|
// return _interaction.editReply('You need to be in the same voice channel as me to make me leave!');
|
||||||
// }
|
// }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const channelId = connection.channelId; // Get channel ID from connection
|
const channelId = connection.channelId; // Get channel ID from connection
|
||||||
const channel = await client.channels.fetch(channelId).catch(() => null); // Fetch channel for name
|
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
|
const channelName = channel && channel.isVoiceBased() ? channel.name : `ID: ${channelId}`; // Get channel name if possible
|
||||||
|
|
||||||
// Use Shoukaku's leave method - this destroys player and connection
|
// Use Shoukaku's leave method - this destroys player and connection
|
||||||
await shoukaku.leaveVoiceChannel(interaction.guildId);
|
await shoukaku.leaveVoiceChannel(_interaction.guildId);
|
||||||
|
|
||||||
logger.info(`Left voice channel ${channelName} in guild ${interaction.guild.name} (${interaction.guildId}) by user ${interaction.user.tag}`);
|
logger.info(
|
||||||
await interaction.editReply(`Left ${channelName}.`);
|
`Left voice channel ${channelName} in guild ${_interaction.guild.name} (${_interaction.guildId}) by user ${_interaction.user.tag}`,
|
||||||
|
);
|
||||||
} catch (error: unknown) { // Type error as unknown
|
await _interaction.editReply(`Left ${channelName}.`);
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
} catch (error: unknown) {
|
||||||
logger.error(`Error leaving voice channel for guild ${interaction.guildId}: ${errorMessage}`, error);
|
// Type error as unknown
|
||||||
// Attempt to reply even if leave failed partially
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
await interaction.editReply('An error occurred while trying to leave the voice channel.').catch((e: unknown) => { // Type catch error
|
logger.error(
|
||||||
const replyErrorMsg = e instanceof Error ? e.message : String(e);
|
`Error leaving voice channel for guild ${_interaction.guildId}: ${errorMessage}`,
|
||||||
logger.error(`Failed to send error reply for leave command: ${replyErrorMsg}`);
|
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,16 +1,22 @@
|
|||||||
import { SlashCommandBuilder, ChatInputCommandInteraction } from 'discord.js';
|
import { SlashCommandBuilder, ChatInputCommandInteraction } from "discord.js";
|
||||||
// No need to import BotClient if not used directly in execute
|
// No need to import BotClient if not used directly in execute
|
||||||
|
|
||||||
export default { // Use export default for ES Modules
|
export default {
|
||||||
data: new SlashCommandBuilder()
|
// Use export default for ES Modules
|
||||||
.setName('ping')
|
data: new SlashCommandBuilder().setName("ping").setDescription("Replies with Pong!"),
|
||||||
.setDescription('Replies with Pong!'),
|
async execute(_interaction: ChatInputCommandInteraction) {
|
||||||
async execute(interaction: ChatInputCommandInteraction) { // Add interaction type
|
// Add _interaction type
|
||||||
// Calculate latency (optional but common for ping commands)
|
// Calculate latency (optional but common for ping commands)
|
||||||
const sent = await interaction.reply({ content: 'Pinging...', fetchReply: true, ephemeral: true });
|
const sent = await _interaction.reply({
|
||||||
const latency = sent.createdTimestamp - interaction.createdTimestamp;
|
content: "Pinging...",
|
||||||
const wsPing = interaction.client.ws.ping; // WebSocket heartbeat ping
|
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`);
|
await _interaction.editReply(
|
||||||
},
|
`Pong! 🏓\nRoundtrip latency: ${latency}ms\nWebSocket Ping: ${wsPing}ms`,
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,347 +1,405 @@
|
|||||||
import {
|
import {
|
||||||
SlashCommandBuilder,
|
SlashCommandBuilder,
|
||||||
SlashCommandStringOption, // Import for typing options
|
SlashCommandStringOption, // Import for typing _options
|
||||||
PermissionFlagsBits,
|
PermissionFlagsBits,
|
||||||
ChannelType,
|
ChannelType,
|
||||||
EmbedBuilder,
|
EmbedBuilder,
|
||||||
ChatInputCommandInteraction,
|
ChatInputCommandInteraction,
|
||||||
GuildMember,
|
GuildMember,
|
||||||
VoiceBasedChannel
|
VoiceBasedChannel,
|
||||||
} from 'discord.js';
|
} from "discord.js";
|
||||||
import logger from '../utils/logger';
|
import logger from "../utils/logger";
|
||||||
import { BotClient } from '../index';
|
import { BotClient } from "../index";
|
||||||
// Import necessary Shoukaku types - LavalinkResponse might need a local definition if not exported
|
// Import necessary Shoukaku types - LavalinkResponse might need a local definition if not exported
|
||||||
import { Player, Node, Track, SearchResult, Connection } from 'shoukaku';
|
import { Player, Node, Track, SearchResult, Connection } from "shoukaku";
|
||||||
|
|
||||||
// Define the structure of the Lavalink V4 response (if not directly available from shoukaku types)
|
// 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
|
// Based on https://lavalink.dev/api/rest.html#load-tracks
|
||||||
type LavalinkLoadType = 'track' | 'playlist' | 'search' | 'empty' | 'error';
|
type LavalinkLoadType = "track" | "playlist" | "search" | "empty" | "error";
|
||||||
|
|
||||||
interface LavalinkResponse {
|
interface LavalinkResponse {
|
||||||
loadType: LavalinkLoadType;
|
loadType: LavalinkLoadType;
|
||||||
data: any; // Data structure varies based on loadType
|
data: any; // Data structure varies based on loadType
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LavalinkErrorData {
|
interface LavalinkErrorData {
|
||||||
message: string;
|
message: string;
|
||||||
severity: string;
|
severity: string;
|
||||||
cause: string;
|
cause: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LavalinkPlaylistInfo {
|
interface LavalinkPlaylistInfo {
|
||||||
name: string;
|
name: string;
|
||||||
selectedTrack?: number; // Optional index of the selected track within the playlist
|
selectedTrack?: number; // Optional index of the selected track within the playlist
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LavalinkPlaylistData {
|
interface LavalinkPlaylistData {
|
||||||
info: LavalinkPlaylistInfo;
|
info: LavalinkPlaylistInfo;
|
||||||
pluginInfo: any; // Or specific type if known
|
pluginInfo: any; // Or specific type if known
|
||||||
tracks: Track[];
|
tracks: Track[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export: Extend Player type locally to add queue and textChannelId
|
// Export: Extend Player type locally to add queue and textChannelId
|
||||||
export interface GuildPlayer extends Player {
|
export interface GuildPlayer extends Player {
|
||||||
queue: TrackWithRequester[];
|
queue: TrackWithRequester[];
|
||||||
textChannelId?: string; // Optional: Store text channel ID for messages
|
textChannelId?: string; // Optional: Store text channel ID for messages
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export: Define TrackWithRequester
|
// Export: Define TrackWithRequester
|
||||||
export interface TrackWithRequester extends Track {
|
export interface TrackWithRequester extends Track {
|
||||||
// Ensure encoded is strictly string if extending base Track which might have it optional
|
// Ensure encoded is strictly string if extending base Track which might have it optional
|
||||||
encoded: string;
|
encoded: string;
|
||||||
requester: {
|
requester: {
|
||||||
id: string;
|
id: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export: Helper function to start playback if possible
|
// Export: Helper function to start playback if possible
|
||||||
export async function playNext(player: GuildPlayer, interaction: ChatInputCommandInteraction) {
|
export async function playNext(player: GuildPlayer, _interaction: ChatInputCommandInteraction) {
|
||||||
// Check if player is still valid (might have been destroyed)
|
// Check if player is still valid (might have been destroyed)
|
||||||
const shoukaku = (interaction.client as BotClient).shoukaku;
|
const shoukaku = (_interaction.client as BotClient).shoukaku;
|
||||||
if (!shoukaku?.players.has(player.guildId)) {
|
if (!shoukaku?.players.has(player.guildId)) {
|
||||||
logger.warn(`playNext called for destroyed player in guild ${player.guildId}`);
|
logger.warn(`playNext called for destroyed player in guild ${player.guildId}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (player.track || player.queue.length === 0) {
|
if (player.track || player.queue.length === 0) {
|
||||||
return; // Already playing or queue is empty
|
return; // Already playing or queue is empty
|
||||||
}
|
}
|
||||||
const nextTrack = player.queue.shift();
|
const nextTrack = player.queue.shift();
|
||||||
if (!nextTrack) return;
|
if (!nextTrack) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if user provided an OAuth token (could be stored in a database or env variable)
|
// Check if user provided an OAuth token (could be stored in a database or env variable)
|
||||||
const oauthToken = process.env.YOUTUBE_OAUTH_TOKEN;
|
const oauthToken = process.env.YOUTUBE_OAUTH_TOKEN;
|
||||||
const userData = oauthToken ? { "oauth-token": oauthToken } : undefined;
|
const userData = oauthToken ? { "oauth-token": oauthToken } : undefined;
|
||||||
// Fix: Correct usage for playTrack based on Player.ts
|
// Fix: Correct usage for playTrack based on Player.ts
|
||||||
await player.playTrack({ track: { encoded: nextTrack.encoded, userData: userData} });
|
await player.playTrack({ track: { encoded: nextTrack.encoded, userData: userData } });
|
||||||
// logger.info(`Started playing: ${nextTrack.info.title} in guild ${player.guildId}`);
|
// logger.info(`Started playing: ${nextTrack.info.title} in guild ${player.guildId}`);
|
||||||
} catch (playError: unknown) {
|
} catch (playError: unknown) {
|
||||||
const errorMsg = playError instanceof Error ? playError.message : String(playError);
|
const errorMsg = playError instanceof Error ? playError.message : String(playError);
|
||||||
logger.error(`Error playing track ${nextTrack.info.title} in guild ${player.guildId}: ${errorMsg}`);
|
logger.error(
|
||||||
// Try to send error message to the stored text channel
|
`Error playing track ${nextTrack.info.title} in guild ${player.guildId}: ${errorMsg}`,
|
||||||
const channel = interaction.guild?.channels.cache.get(player.textChannelId || interaction.channelId);
|
);
|
||||||
if (channel?.isTextBased()) {
|
// Try to send error message to the stored text channel
|
||||||
// Fix: Check if e is Error before accessing message
|
const channel = _interaction.guild?.channels.cache.get(
|
||||||
channel.send(`Error playing track: ${nextTrack.info.title}. Reason: ${errorMsg}`).catch((e: unknown) => {
|
player.textChannelId || _interaction.channelId,
|
||||||
const sendErrorMsg = e instanceof Error ? e.message : String(e);
|
);
|
||||||
logger.error(`Failed to send play error message: ${sendErrorMsg}`);
|
if (channel?.isTextBased()) {
|
||||||
});
|
// Fix: Check if e is Error before accessing message
|
||||||
}
|
channel
|
||||||
// Try playing the next track if available
|
.send(`Error playing track: ${nextTrack.info.title}. Reason: ${errorMsg}`)
|
||||||
await playNext(player, interaction);
|
.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 {
|
export default {
|
||||||
data: new SlashCommandBuilder()
|
data: new SlashCommandBuilder()
|
||||||
.setName('play')
|
.setName("play")
|
||||||
.setDescription('Plays audio from a URL or search query')
|
.setDescription("Plays audio from a URL or search query")
|
||||||
.addStringOption((option: SlashCommandStringOption) => // Type option
|
.addStringOption(
|
||||||
option.setName('query')
|
(
|
||||||
.setDescription('The URL or search term for the song/playlist')
|
option: SlashCommandStringOption, // Type option
|
||||||
.setRequired(true))
|
) =>
|
||||||
.addStringOption((option: SlashCommandStringOption) => // Type option
|
option
|
||||||
option.setName('source')
|
.setName("query")
|
||||||
.setDescription('Specify the search source (defaults to YouTube Music)')
|
.setDescription("The URL or search term for the song/playlist")
|
||||||
.setRequired(false)
|
.setRequired(true),
|
||||||
.addChoices(
|
)
|
||||||
{ name: 'YouTube Music', value: 'youtubemusic' },
|
.addStringOption(
|
||||||
{ name: 'YouTube', value: 'youtube' },
|
(
|
||||||
{ name: 'SoundCloud', value: 'soundcloud' }
|
option: SlashCommandStringOption, // Type option
|
||||||
// Add other sources like 'spotify' if supported by Lavalink plugins
|
) =>
|
||||||
)),
|
option
|
||||||
async execute(interaction: ChatInputCommandInteraction, client: BotClient) {
|
.setName("source")
|
||||||
// Ensure command is run in a guild
|
.setDescription("Specify the search source (defaults to YouTube Music)")
|
||||||
if (!interaction.guildId || !interaction.guild || !interaction.channelId) {
|
.setRequired(false)
|
||||||
return interaction.reply({ content: 'This command can only be used in a server.', ephemeral: true }).catch(() => {});
|
.addChoices(
|
||||||
}
|
{ name: "YouTube Music", value: "youtubemusic" },
|
||||||
if (!(interaction.member instanceof GuildMember)) {
|
{ name: "YouTube", value: "youtube" },
|
||||||
return interaction.reply({ content: 'Could not determine your voice channel.', ephemeral: true }).catch(() => {});
|
{ 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
|
await _interaction.deferReply(); // Defer reply immediately
|
||||||
|
|
||||||
const member = interaction.member;
|
const member = _interaction.member;
|
||||||
const voiceChannel = member?.voice?.channel;
|
const voiceChannel = member?.voice?.channel;
|
||||||
const query = interaction.options.getString('query', true); // Required option
|
const query = _interaction.options.getString("query", true); // Required option
|
||||||
const source = interaction.options.getString('source'); // Optional
|
const source = _interaction.options.getString("source"); // Optional
|
||||||
|
|
||||||
// 1. Check if user is in a voice channel
|
// 1. Check if user is in a voice channel
|
||||||
if (!voiceChannel) {
|
if (!voiceChannel) {
|
||||||
return interaction.editReply('You need to be in a voice channel to play music!');
|
return _interaction.editReply("You need to be in a voice channel to play music!");
|
||||||
}
|
}
|
||||||
const currentVoiceChannel = voiceChannel as VoiceBasedChannel;
|
const currentVoiceChannel = voiceChannel as VoiceBasedChannel;
|
||||||
|
|
||||||
// 2. Check bot permissions
|
// 2. Check bot permissions
|
||||||
const permissions = currentVoiceChannel.permissionsFor(client.user!);
|
const permissions = currentVoiceChannel.permissionsFor(_client.user!);
|
||||||
if (!permissions?.has(PermissionFlagsBits.Connect)) {
|
if (!permissions?.has(PermissionFlagsBits.Connect)) {
|
||||||
return interaction.editReply('I need permission to **connect** to your voice channel!');
|
return _interaction.editReply("I need permission to **connect** to your voice channel!");
|
||||||
}
|
}
|
||||||
if (!permissions?.has(PermissionFlagsBits.Speak)) {
|
if (!permissions?.has(PermissionFlagsBits.Speak)) {
|
||||||
return interaction.editReply('I need permission to **speak** in your voice channel!');
|
return _interaction.editReply("I need permission to **speak** in your voice channel!");
|
||||||
}
|
}
|
||||||
if (currentVoiceChannel.type !== ChannelType.GuildVoice) {
|
if (currentVoiceChannel.type !== ChannelType.GuildVoice) {
|
||||||
return interaction.editReply('I can only join standard voice channels.');
|
return _interaction.editReply("I can only join standard voice channels.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get Shoukaku instance
|
// Get Shoukaku instance
|
||||||
const shoukaku = client.shoukaku;
|
const shoukaku = _client.shoukaku;
|
||||||
if (!shoukaku) {
|
if (!shoukaku) {
|
||||||
logger.error('Shoukaku instance not found on client object!');
|
logger.error("Shoukaku instance not found on _client object!");
|
||||||
return interaction.editReply('The music player is not ready yet. Please try again shortly.');
|
return _interaction.editReply("The music player is not ready yet. Please try again shortly.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let player: GuildPlayer | undefined; // Declare player variable outside try block
|
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);
|
||||||
|
|
||||||
|
if (!player || !connection || connection.channelId !== currentVoiceChannel.id) {
|
||||||
|
// If player/connection doesn't exist or bot is in wrong channel, join/move
|
||||||
try {
|
try {
|
||||||
// 3. Get or create player/connection
|
player = (await shoukaku.joinVoiceChannel({
|
||||||
player = shoukaku.players.get(interaction.guildId) as GuildPlayer | undefined;
|
guildId: _interaction.guildId,
|
||||||
const connection = shoukaku.connections.get(interaction.guildId);
|
channelId: currentVoiceChannel.id,
|
||||||
|
shardId: _interaction.guild.shardId,
|
||||||
if (!player || !connection || connection.channelId !== currentVoiceChannel.id) {
|
})) as GuildPlayer; // Cast to extended type
|
||||||
// If player/connection doesn't exist or bot is in wrong channel, join/move
|
logger.info(
|
||||||
try {
|
`Joined/Moved to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) for play command.`,
|
||||||
player = await shoukaku.joinVoiceChannel({
|
);
|
||||||
guildId: interaction.guildId,
|
// Initialize queue if it's a new player
|
||||||
channelId: currentVoiceChannel.id,
|
if (!player.queue) {
|
||||||
shardId: interaction.guild.shardId,
|
player.queue = [];
|
||||||
}) as GuildPlayer; // Cast to extended type
|
}
|
||||||
logger.info(`Joined/Moved to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) for play command.`);
|
player.textChannelId = _interaction.channelId; // Store text channel context
|
||||||
// Initialize queue if it's a new player
|
} catch (joinError: unknown) {
|
||||||
if (!player.queue) {
|
const errorMsg = joinError instanceof Error ? joinError.message : String(joinError);
|
||||||
player.queue = [];
|
logger.error(
|
||||||
}
|
`Failed to join/move player for guild ${_interaction.guildId}: ${errorMsg}`,
|
||||||
player.textChannelId = interaction.channelId; // Store text channel context
|
joinError,
|
||||||
|
);
|
||||||
} catch (joinError: unknown) {
|
shoukaku.leaveVoiceChannel(_interaction.guildId).catch(() => {}); // Attempt cleanup
|
||||||
const errorMsg = joinError instanceof Error ? joinError.message : String(joinError);
|
return _interaction.editReply(
|
||||||
logger.error(`Failed to join/move player for guild ${interaction.guildId}: ${errorMsg}`, joinError);
|
"An error occurred while trying to join the voice channel.",
|
||||||
shoukaku.leaveVoiceChannel(interaction.guildId).catch(() => {}); // Attempt cleanup
|
);
|
||||||
return interaction.editReply('An error occurred while trying to join the voice channel.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 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})`)
|
|
||||||
// Ensure player exists before accessing queue
|
|
||||||
.addFields({ name: 'Position in queue', value: `${player.queue.length + 1}`, 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 + 1}`, 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(() => {});
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
},
|
} else {
|
||||||
|
// 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})`)
|
||||||
|
// Ensure player exists before accessing queue
|
||||||
|
.addFields({
|
||||||
|
name: "Position in queue",
|
||||||
|
value: `${player.queue.length + 1}`,
|
||||||
|
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 + 1}`,
|
||||||
|
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,64 +1,37 @@
|
|||||||
import { Events, Interaction } from 'discord.js'; // Import Interaction type
|
import { Events, Interaction } from "discord.js";
|
||||||
import logger from '../utils/logger'; // Use default import
|
import { BotClient } from "../types/botClient";
|
||||||
import { BotClient } from '../index'; // Import BotClient type
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
export default { // Use export default
|
export default {
|
||||||
name: Events.InteractionCreate,
|
name: Events.InteractionCreate,
|
||||||
async execute(interaction: Interaction, client: BotClient) { // Add types
|
async execute(interaction: Interaction, client?: BotClient) {
|
||||||
// Handle only slash commands (ChatInputCommand) for now
|
if (!interaction.isChatInputCommand()) return;
|
||||||
if (!interaction.isChatInputCommand()) return;
|
|
||||||
|
|
||||||
// Store command name after type check
|
if (!client) {
|
||||||
const commandName = interaction.commandName;
|
logger.error("Client not provided to interaction handler");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// client.commands should be typed as Collection<string, CommandType> on BotClient
|
const command = client.commands.get(interaction.commandName);
|
||||||
const command = client.commands.get(commandName);
|
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
logger.error(`No command matching ${commandName} was found.`);
|
await interaction.reply({
|
||||||
try {
|
content: "Command not found!",
|
||||||
// Check if interaction is replyable before attempting reply
|
ephemeral: true,
|
||||||
if (interaction.isRepliable()) {
|
});
|
||||||
await interaction.reply({ content: 'Error: This command was not found!', ephemeral: true });
|
return;
|
||||||
}
|
}
|
||||||
} catch (replyError: unknown) { // Type caught error
|
|
||||||
const errorMsg = replyError instanceof Error ? replyError.message : String(replyError);
|
|
||||||
// Use stored commandName variable
|
|
||||||
logger.error(`Failed to send 'command not found' reply for command '${commandName}': ${errorMsg}`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Execute the command's logic
|
await command.execute(interaction, client);
|
||||||
// Command execute function expects ChatInputCommandInteraction, but we check type above
|
} catch (error) {
|
||||||
await command.execute(interaction, client);
|
logger.error(`Error executing command ${interaction.commandName}:`, error);
|
||||||
logger.info(`Executed command '${commandName}' for user ${interaction.user.tag}`);
|
if (interaction.isRepliable()) {
|
||||||
} catch (error: unknown) { // Type caught error
|
await interaction.reply({
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
content: "There was an error while executing this command!",
|
||||||
// Use stored commandName variable
|
ephemeral: true,
|
||||||
logger.error(`Error executing command '${commandName}': ${errorMsg}`, 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 {
|
|
||||||
// Check if interaction is replyable before attempting reply/followUp
|
|
||||||
if (!interaction.isRepliable()) {
|
|
||||||
// Use stored commandName variable
|
|
||||||
logger.warn(`Interaction for command '${commandName}' is no longer replyable.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (interaction.replied || interaction.deferred) {
|
|
||||||
await interaction.followUp(replyOptions);
|
|
||||||
} else {
|
|
||||||
await interaction.reply(replyOptions);
|
|
||||||
}
|
|
||||||
} catch (replyError: unknown) { // Type caught error
|
|
||||||
const replyErrorMsg = replyError instanceof Error ? replyError.message : String(replyError);
|
|
||||||
// Use stored commandName variable
|
|
||||||
logger.error(`Failed to send error reply for command '${commandName}': ${replyErrorMsg}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,31 +1,34 @@
|
|||||||
import { Events, ActivityType, Client } from 'discord.js'; // Import base Client type
|
import { Events, ActivityType, Client } from "discord.js"; // Import base Client type
|
||||||
import logger from '../utils/logger'; // Use default import
|
import logger from "../utils/logger"; // Use default import
|
||||||
import { initializeShoukaku } from '../structures/ShoukakuEvents'; // Import the correct setup function
|
import { initializeShoukaku } from "../structures/ShoukakuEvents"; // Import the correct setup function
|
||||||
import { BotClient } from '../index'; // Import BotClient type
|
import { BotClient } from "../index"; // Import BotClient type
|
||||||
|
|
||||||
export default { // Use export default
|
export default {
|
||||||
name: Events.ClientReady,
|
// Use export default
|
||||||
once: true, // This event should only run once
|
name: Events.ClientReady,
|
||||||
async execute(client: BotClient) { // Use BotClient type
|
once: true, // This event should only run once
|
||||||
// Ensure client.user is available
|
async execute(_client: BotClient) {
|
||||||
if (!client.user) {
|
// Use BotClient type
|
||||||
logger.error('Client user is not available on ready event.');
|
// Ensure _client.user is available
|
||||||
return;
|
if (!_client.user) {
|
||||||
}
|
logger.error("Client user is not available on ready event.");
|
||||||
logger.info(`Ready! Logged in as ${client.user.tag}`);
|
return;
|
||||||
|
}
|
||||||
|
logger.info(`Ready! Logged in as ${_client.user.tag}`);
|
||||||
|
|
||||||
// Initialize the Shoukaku instance and attach listeners
|
// Initialize the Shoukaku instance and attach listeners
|
||||||
try {
|
try {
|
||||||
// Assign the initialized Shoukaku instance to client.shoukaku
|
// Assign the initialized Shoukaku instance to _client.shoukaku
|
||||||
client.shoukaku = initializeShoukaku(client);
|
_client.shoukaku = initializeShoukaku(_client);
|
||||||
logger.info('Shoukaku instance initialized successfully'); // Log message adjusted slightly
|
logger.info("Shoukaku instance initialized successfully"); // Log message adjusted slightly
|
||||||
} catch (error: unknown) { // Type caught error
|
} catch (error: unknown) {
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
// Type caught error
|
||||||
logger.error(`Failed to initialize Shoukaku: ${errorMsg}`);
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
// Depending on the severity, you might want to exit or handle this differently
|
logger.error(`Failed to initialize Shoukaku: ${errorMsg}`);
|
||||||
}
|
// Depending on the severity, you might want to exit or handle this differently
|
||||||
|
}
|
||||||
|
|
||||||
// Set activity status
|
// Set activity status
|
||||||
client.user.setActivity('Music | /play', { type: ActivityType.Listening });
|
_client.user.setActivity("Music | /play", { type: ActivityType.Listening });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,60 +1,74 @@
|
|||||||
import { Events, VoiceState, ChannelType } from 'discord.js'; // Added ChannelType
|
import { Events, VoiceState, ChannelType } from "discord.js"; // Added ChannelType
|
||||||
import logger from '../utils/logger';
|
import logger from "../utils/logger";
|
||||||
import { BotClient } from '../index'; // Assuming BotClient is exported from index
|
import { BotClient } from "../index"; // Assuming BotClient is exported from index
|
||||||
|
|
||||||
export default { // Use export default for ES modules
|
export default {
|
||||||
name: Events.VoiceStateUpdate,
|
// Use export default for ES modules
|
||||||
execute(oldState: VoiceState, newState: VoiceState, client: BotClient) { // Added types
|
name: Events.VoiceStateUpdate,
|
||||||
// Shoukaku handles voice state updates internally via its connector.
|
execute(oldState: VoiceState, newState: VoiceState, _client: BotClient) {
|
||||||
// We don't need to manually pass the update like with Erela.js.
|
// Added types
|
||||||
// The warning about Erela.js manager not being initialized can be ignored/removed.
|
// 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.
|
// Custom logic for player cleanup based on voice state changes.
|
||||||
const shoukaku = client.shoukaku; // Access Shoukaku instance
|
const shoukaku = _client.shoukaku; // Access Shoukaku instance
|
||||||
if (!shoukaku) {
|
if (!shoukaku) {
|
||||||
// Shoukaku might not be initialized yet
|
// Shoukaku might not be initialized yet
|
||||||
logger.debug('Voice state update received, but Shoukaku is not ready yet.');
|
logger.debug("Voice state update received, but Shoukaku is not ready yet.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const player = shoukaku.players.get(newState.guild.id); // Get player from Shoukaku players collection
|
const player = shoukaku.players.get(newState.guild.id); // Get player from Shoukaku players collection
|
||||||
if (!player) return; // No active player for this guild
|
if (!player) return; // No active player for this guild
|
||||||
|
|
||||||
// Get the connection associated with the player's guild
|
// Get the connection associated with the player's guild
|
||||||
const connection = shoukaku.connections.get(player.guildId);
|
const connection = shoukaku.connections.get(player.guildId);
|
||||||
const currentChannelId = connection?.channelId; // Get channelId from connection
|
const currentChannelId = connection?.channelId; // Get channelId from connection
|
||||||
|
|
||||||
// Check if the bot was disconnected (newState has no channelId for the bot)
|
// Check if the bot was disconnected (newState has no channelId for the bot)
|
||||||
// Add null check for client.user
|
// Add null check for _client.user
|
||||||
if (client.user && newState.id === client.user.id && !newState.channelId && oldState.channelId === currentChannelId) {
|
if (
|
||||||
logger.info(`Bot was disconnected from voice channel ${oldState.channel?.name || oldState.channelId} in guild ${newState.guild.id}. Destroying player.`);
|
_client.user &&
|
||||||
player.destroy(); // Use Shoukaku player's destroy method
|
newState.id === _client.user.id &&
|
||||||
return; // Exit early as the player is destroyed
|
!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)
|
// Check if the bot's channel is now empty (excluding the bot itself)
|
||||||
const channel = currentChannelId ? client.channels.cache.get(currentChannelId) : undefined;
|
const channel = currentChannelId ? _client.channels.cache.get(currentChannelId) : undefined;
|
||||||
|
|
||||||
// Ensure the channel exists, is voice-based, and the update is relevant
|
// Ensure the channel exists, is voice-based, and the update is relevant
|
||||||
if (channel?.isVoiceBased() && (newState.channelId === currentChannelId || oldState.channelId === currentChannelId)) {
|
if (
|
||||||
// Fetch members again to ensure freshness after the update
|
channel?.isVoiceBased() &&
|
||||||
const members = channel.members; // Safe to access members now
|
(newState.channelId === currentChannelId || oldState.channelId === currentChannelId)
|
||||||
// Add null check for client.user
|
) {
|
||||||
if (client.user && members.size === 1 && members.has(client.user.id)) {
|
// Fetch members again to ensure freshness after the update
|
||||||
logger.info(`Voice channel ${channel.name} (${currentChannelId}) in guild ${newState.guild.id} is now empty (only bot left). Destroying player.`); // Safe to access name
|
const members = channel.members; // Safe to access members now
|
||||||
// Optional: Add a timeout before destroying
|
// Add null check for _client.user
|
||||||
// setTimeout(() => {
|
if (_client.user && members.size === 1 && members.has(_client.user.id)) {
|
||||||
// const currentChannel = client.channels.cache.get(player.voiceChannel);
|
logger.info(
|
||||||
// const currentMembers = currentChannel?.members;
|
`Voice channel ${channel.name} (${currentChannelId}) in guild ${newState.guild.id} is now empty (only bot left). Destroying player.`,
|
||||||
// if (currentMembers && currentMembers.size === 1 && currentMembers.has(client.user.id)) {
|
); // Safe to access name
|
||||||
// logger.info(`Timeout finished: Destroying player in empty channel ${channel.name}.`);
|
// Optional: Add a timeout before destroying
|
||||||
// player.destroy();
|
// setTimeout(() => {
|
||||||
// } else {
|
// const currentChannel = _client.channels.cache.get(player.voiceChannel);
|
||||||
// logger.info(`Timeout finished: Channel ${channel.name} is no longer empty. Player not destroyed.`);
|
// const currentMembers = currentChannel?.members;
|
||||||
// }
|
// if (currentMembers && currentMembers.size === 1 && currentMembers.has(_client.user.id)) {
|
||||||
// }, 60000); // e.g., 1 minute timeout
|
// logger.info(`Timeout finished: Destroying player in empty channel ${channel.name}.`);
|
||||||
player.destroy(); // Destroy immediately for now
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
246
src/index.ts
246
src/index.ts
@ -1,35 +1,36 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from "dotenv";
|
||||||
import {
|
import {
|
||||||
Client,
|
Client,
|
||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
Collection,
|
Collection,
|
||||||
Events,
|
Events,
|
||||||
BaseInteraction, // Use a base type for now, refine later if needed
|
BaseInteraction, // Use a base type for now, refine later if needed
|
||||||
SlashCommandBuilder, // Assuming commands use this
|
SlashCommandBuilder, // Assuming commands use this
|
||||||
} from 'discord.js';
|
} from "discord.js";
|
||||||
import { Shoukaku, Connectors, NodeOption, ShoukakuOptions } from 'shoukaku';
|
import { Shoukaku, Connectors, NodeOption, ShoukakuOptions } from "shoukaku";
|
||||||
import logger from './utils/logger'; // Assuming logger uses export default or similar
|
import logger from "./utils/logger"; // Assuming logger uses export default or similar
|
||||||
import fs from 'fs';
|
import fs from "fs";
|
||||||
import path from 'path';
|
import path from "path";
|
||||||
// import { fileURLToPath } from 'url'; // Needed for __dirname in ES Modules if module is not CommonJS
|
// import { fileURLToPath } from 'url'; // Needed for __dirname in ES Modules if module is not CommonJS
|
||||||
|
|
||||||
// Define Command structure
|
// Define Command structure
|
||||||
interface Command {
|
interface Command {
|
||||||
data: Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">; // Or appropriate type for your command data
|
data: Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">; // Or appropriate type for your command data
|
||||||
execute: (interaction: BaseInteraction, client: BotClient) => Promise<void>; // Adjust interaction type if needed
|
execute: (_interaction: BaseInteraction, _client: BotClient) => Promise<void>; // Adjust _interaction type if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define Event structure
|
// Define Event structure
|
||||||
interface BotEvent {
|
interface BotEvent {
|
||||||
name: string; // Should match discord.js event names or custom names
|
name: string; // Should match discord.js event names or custom names
|
||||||
once?: boolean;
|
once?: boolean;
|
||||||
execute: (...args: any[]) => void; // Use specific types later if possible
|
execute: (..._args: any[]) => void; // Use specific types later if possible
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extend the discord.js Client class to include custom properties
|
// Extend the discord.js Client class to include custom properties
|
||||||
export interface BotClient extends Client { // Add export keyword
|
export interface BotClient extends Client {
|
||||||
commands: Collection<string, Command>;
|
// Add export keyword
|
||||||
shoukaku: Shoukaku;
|
commands: Collection<string, Command>;
|
||||||
|
shoukaku: Shoukaku;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Setup ---
|
// --- Setup ---
|
||||||
@ -38,149 +39,164 @@ dotenv.config();
|
|||||||
|
|
||||||
// Validate essential environment variables
|
// Validate essential environment variables
|
||||||
if (!process.env.DISCORD_TOKEN) {
|
if (!process.env.DISCORD_TOKEN) {
|
||||||
logger.error('DISCORD_TOKEN is missing in the .env file!');
|
logger.error("DISCORD_TOKEN is missing in the .env file!");
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
if (!process.env.LAVALINK_HOST || !process.env.LAVALINK_PORT || !process.env.LAVALINK_PASSWORD) {
|
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.');
|
logger.warn(
|
||||||
// Decide if the bot should exit or continue without music
|
"Lavalink connection details (HOST, PORT, PASSWORD) are missing or incomplete in .env. Music functionality will be limited.",
|
||||||
// process.exit(1); // Uncomment to exit if Lavalink is mandatory
|
);
|
||||||
|
// 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
|
// Create a new Discord _client instance with necessary intents
|
||||||
const client = new Client({
|
const _client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
GatewayIntentBits.GuildVoiceStates,
|
GatewayIntentBits.GuildVoiceStates,
|
||||||
GatewayIntentBits.GuildMessages, // Add if needed for prefix commands or message content
|
GatewayIntentBits.GuildMessages, // Add if needed for prefix commands or message content
|
||||||
GatewayIntentBits.MessageContent, // Add if needed for message content
|
GatewayIntentBits.MessageContent, // Add if needed for message content
|
||||||
],
|
],
|
||||||
}) as BotClient; // Assert the type here
|
}) as BotClient; // Assert the type here
|
||||||
|
|
||||||
// Define Shoukaku nodes
|
// Define Shoukaku nodes
|
||||||
const Nodes: NodeOption[] = [
|
const Nodes: NodeOption[] = [
|
||||||
{
|
{
|
||||||
name: process.env.LAVALINK_NAME || 'lavalink-node-1', // Use an env var or default name
|
name: process.env.LAVALINK_NAME || "lavalink-node-1", // Use an env var or default name
|
||||||
url: `${process.env.LAVALINK_HOST || 'localhost'}:${process.env.LAVALINK_PORT || 2333}`, // Use || 2333 for default port number
|
url: `${process.env.LAVALINK_HOST || "localhost"}:${process.env.LAVALINK_PORT || 2333}`, // Use || 2333 for default port number
|
||||||
auth: process.env.LAVALINK_PASSWORD || 'youshallnotpass', // Password from your Lavalink server config
|
auth: process.env.LAVALINK_PASSWORD || "youshallnotpass", // Password from your Lavalink server config
|
||||||
secure: process.env.LAVALINK_SECURE === 'true', // Set to true if using HTTPS/WSS
|
secure: process.env.LAVALINK_SECURE === "true", // Set to true if using HTTPS/WSS
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Shoukaku options
|
// Shoukaku _options
|
||||||
const shoukakuOptions: ShoukakuOptions = {
|
const shoukakuOptions: ShoukakuOptions = {
|
||||||
moveOnDisconnect: false, // Whether to move players to another node when a node disconnects
|
moveOnDisconnect: false, // Whether to move players to another node when a node disconnects
|
||||||
resume: true, // Whether to resume players session after Lavalink restarts
|
resume: true, // Whether to resume players session after Lavalink restarts
|
||||||
reconnectTries: 3, // Number of attempts to reconnect to Lavalink
|
reconnectTries: 3, // Number of attempts to reconnect to Lavalink
|
||||||
reconnectInterval: 5000, // Interval between reconnect attempts in milliseconds
|
reconnectInterval: 5000, // Interval between reconnect attempts in milliseconds
|
||||||
// Add other options as needed
|
// Add other _options as needed
|
||||||
};
|
};
|
||||||
|
|
||||||
// Initialize Shoukaku
|
// Initialize Shoukaku
|
||||||
client.shoukaku = new Shoukaku(new Connectors.DiscordJS(client), Nodes, shoukakuOptions);
|
_client.shoukaku = new Shoukaku(new Connectors.DiscordJS(_client), Nodes, shoukakuOptions);
|
||||||
|
|
||||||
// Show the actual Lavalink connection details (without exposing the actual password)
|
// 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]'})`);
|
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
|
// Collections for commands
|
||||||
client.commands = new Collection<string, Command>();
|
_client.commands = new Collection<string, Command>();
|
||||||
|
|
||||||
// --- Command Loading ---
|
// --- Command Loading ---
|
||||||
const commandsPath = path.join(__dirname, 'commands');
|
const commandsPath = path.join(__dirname, "commands");
|
||||||
// Read .ts files now
|
// Read .ts files now
|
||||||
const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith('.ts'));
|
const commandFiles = fs.readdirSync(commandsPath).filter((file: string) => file.endsWith(".ts"));
|
||||||
|
|
||||||
const loadCommands = async () => {
|
const loadCommands = async () => {
|
||||||
for (const file of commandFiles) {
|
for (const file of commandFiles) {
|
||||||
const filePath = path.join(commandsPath, file);
|
const filePath = path.join(commandsPath, file);
|
||||||
try {
|
try {
|
||||||
// Use dynamic import for ES Modules/CommonJS interop
|
// Use dynamic import for ES Modules/CommonJS interop
|
||||||
const commandModule = await import(filePath);
|
const commandModule = await import(filePath);
|
||||||
const command: Command = commandModule.default || commandModule; // Handle default exports
|
const command: Command = commandModule.default || commandModule; // Handle default exports
|
||||||
|
|
||||||
if (command && typeof command === 'object' && 'data' in command && 'execute' in command) {
|
if (command && typeof command === "object" && "data" in command && "execute" in command) {
|
||||||
client.commands.set(command.data.name, command);
|
_client.commands.set(command.data.name, command);
|
||||||
logger.info(`Loaded command: ${command.data.name}`);
|
logger.info(`Loaded command: ${command.data.name}`);
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property or is not structured correctly.`);
|
logger.warn(
|
||||||
}
|
`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property or is not structured correctly.`,
|
||||||
} catch (error: unknown) { // Type the error as unknown
|
);
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
}
|
||||||
logger.error(`Error loading command at ${filePath}: ${errorMessage}`, error);
|
} catch (error: unknown) {
|
||||||
}
|
// Type the error as unknown
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
logger.error(`Error loading command at ${filePath}: ${errorMessage}`, error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Event Handling ---
|
// --- Event Handling ---
|
||||||
const eventsPath = path.join(__dirname, 'events');
|
const eventsPath = path.join(__dirname, "events");
|
||||||
// Read .ts files now
|
// Read .ts files now
|
||||||
const eventFiles = fs.readdirSync(eventsPath).filter((file: string) => file.endsWith('.ts'));
|
const eventFiles = fs.readdirSync(eventsPath).filter((file: string) => file.endsWith(".ts"));
|
||||||
|
|
||||||
const loadEvents = async () => {
|
const loadEvents = async () => {
|
||||||
for (const file of eventFiles) {
|
for (const file of eventFiles) {
|
||||||
const filePath = path.join(eventsPath, file);
|
const filePath = path.join(eventsPath, file);
|
||||||
try {
|
try {
|
||||||
const eventModule = await import(filePath);
|
const eventModule = await import(filePath);
|
||||||
const event: BotEvent = eventModule.default || eventModule; // Handle default exports
|
const event: BotEvent = eventModule.default || eventModule; // Handle default exports
|
||||||
|
|
||||||
if (event && typeof event === 'object' && 'name' in event && 'execute' in event) {
|
if (event && typeof event === "object" && "name" in event && "execute" in event) {
|
||||||
if (event.once) {
|
if (event.once) {
|
||||||
client.once(event.name, (...args: any[]) => event.execute(...args, client)); // Pass client
|
_client.once(event.name, (..._args: any[]) => event.execute(..._args, _client)); // Pass _client
|
||||||
logger.info(`Loaded event ${event.name} (once)`);
|
logger.info(`Loaded event ${event.name} (once)`);
|
||||||
} else {
|
} else {
|
||||||
client.on(event.name, (...args: any[]) => event.execute(...args, client)); // Pass client
|
_client.on(event.name, (..._args: any[]) => event.execute(..._args, _client)); // Pass _client
|
||||||
logger.info(`Loaded event ${event.name}`);
|
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);
|
|
||||||
}
|
}
|
||||||
|
} 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 ---
|
// --- Shoukaku Event Handling ---
|
||||||
client.shoukaku.on('ready', (name: string) => logger.info(`Lavalink Node: ${name} is now connected`));
|
_client.shoukaku.on("ready", (name: string) =>
|
||||||
client.shoukaku.on('error', (name: string, error: Error) => logger.error(`Lavalink Node: ${name} emitted an error: ${error.message}`));
|
logger.info(`Lavalink Node: ${name} is now connected`),
|
||||||
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("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"}`),
|
||||||
|
);
|
||||||
// Corrected disconnect event signature based on common usage and error TS148
|
// Corrected disconnect event signature based on common usage and error TS148
|
||||||
client.shoukaku.on('disconnect', (name: string, count: number) => {
|
_client.shoukaku.on("disconnect", (name: string, count: number) => {
|
||||||
logger.warn(`Lavalink Node: ${name} disconnected. ${count} players were disconnected from this node.`);
|
logger.warn(
|
||||||
|
`Lavalink Node: ${name} disconnected. ${count} players were disconnected from this node.`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// --- Main Execution ---
|
// --- Main Execution ---
|
||||||
async function main() {
|
async function main() {
|
||||||
await loadCommands();
|
await loadCommands();
|
||||||
await loadEvents();
|
await loadEvents();
|
||||||
|
|
||||||
// Log in to Discord with your client's token
|
// Log in to Discord with your _client's token
|
||||||
try {
|
try {
|
||||||
await client.login(process.env.DISCORD_TOKEN);
|
await _client.login(process.env.DISCORD_TOKEN);
|
||||||
logger.info('Successfully logged in to Discord.');
|
logger.info("Successfully logged in to Discord.");
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
logger.error(`Failed to log in: ${errorMessage}`);
|
logger.error(`Failed to log in: ${errorMessage}`);
|
||||||
process.exit(1); // Exit if login fails
|
process.exit(1); // Exit if login fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error) => {
|
main().catch((error) => {
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
logger.error(`Error during bot initialization: ${errorMessage}`, error);
|
logger.error(`Error during bot initialization: ${errorMessage}`, error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Basic error handling
|
// Basic error handling
|
||||||
process.on('unhandledRejection', (reason: unknown, promise: Promise<any>) => {
|
process.on("unhandledRejection", (reason: unknown, promise: Promise<any>) => {
|
||||||
const reasonMessage = reason instanceof Error ? reason.message : String(reason);
|
const reasonMessage = reason instanceof Error ? reason.message : String(reason);
|
||||||
logger.error('Unhandled promise rejection:', { reason: reasonMessage, promise });
|
logger.error("Unhandled promise rejection:", { reason: reasonMessage, promise });
|
||||||
});
|
});
|
||||||
process.on('uncaughtException', (error: Error, origin: NodeJS.UncaughtExceptionOrigin) => {
|
process.on("uncaughtException", (error: Error, origin: NodeJS.UncaughtExceptionOrigin) => {
|
||||||
logger.error(`Uncaught exception: ${error.message}`, { error, origin });
|
logger.error(`Uncaught exception: ${error.message}`, { error, origin });
|
||||||
// Optional: exit process on critical uncaught exceptions
|
// Optional: exit process on critical uncaught exceptions
|
||||||
// process.exit(1);
|
// process.exit(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,67 +1,67 @@
|
|||||||
import { Shoukaku, NodeOption, ShoukakuOptions, Player, Connectors } from 'shoukaku'; // Removed player event types, Added Connectors
|
import { Shoukaku, NodeOption, ShoukakuOptions, Player, Connectors } from "shoukaku"; // Removed player event types, Added Connectors
|
||||||
// import { Connectors } from 'shoukaku-discord.js'; // Use the discord.js connector - Removed this line
|
// import { Connectors } from 'shoukaku-discord.js'; // Use the discord.js connector - Removed this line
|
||||||
import logger from '../utils/logger';
|
import logger from "../utils/logger";
|
||||||
import { BotClient } from '../index';
|
import { BotClient } from "../index";
|
||||||
// Removed imports from play.ts for now as player listeners are removed
|
// Removed imports from play.ts for now as player listeners are removed
|
||||||
|
|
||||||
// Define Node options (replace with your actual Lavalink details from .env)
|
// Define Node _options (replace with your actual Lavalink details from .env)
|
||||||
const nodes: NodeOption[] = [
|
const nodes: NodeOption[] = [
|
||||||
{
|
{
|
||||||
name: process.env.LAVALINK_NAME || 'Lavalink-Node-1',
|
name: process.env.LAVALINK_NAME || "Lavalink-Node-1",
|
||||||
url: process.env.LAVALINK_URL || 'lavalink:2333', // Use service name for Docker Compose if applicable
|
url: process.env.LAVALINK_URL || "lavalink:2333", // Use service name for Docker Compose if applicable
|
||||||
auth: process.env.LAVALINK_AUTH || 'youshallnotpass',
|
auth: process.env.LAVALINK_AUTH || "youshallnotpass",
|
||||||
secure: process.env.LAVALINK_SECURE === 'true' || false,
|
secure: process.env.LAVALINK_SECURE === "true" || false,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Define Shoukaku options
|
// Define Shoukaku _options
|
||||||
const shoukakuOptions: ShoukakuOptions = {
|
const shoukakuOptions: ShoukakuOptions = {
|
||||||
moveOnDisconnect: false,
|
moveOnDisconnect: false,
|
||||||
resume: false, // Resume doesn't work reliably across restarts/disconnects without session persistence
|
resume: false, // Resume doesn't work reliably across restarts/disconnects without session persistence
|
||||||
reconnectTries: 3,
|
reconnectTries: 3,
|
||||||
reconnectInterval: 5, // In seconds
|
reconnectInterval: 5, // In seconds
|
||||||
restTimeout: 15000, // In milliseconds
|
restTimeout: 15000, // In milliseconds
|
||||||
voiceConnectionTimeout: 15, // In seconds
|
voiceConnectionTimeout: 15, // In seconds
|
||||||
};
|
};
|
||||||
|
|
||||||
// Function to initialize Shoukaku and attach listeners
|
// Function to initialize Shoukaku and attach listeners
|
||||||
export function initializeShoukaku(client: BotClient): Shoukaku {
|
export function initializeShoukaku(_client: BotClient): Shoukaku {
|
||||||
if (!client) {
|
if (!_client) {
|
||||||
throw new Error("initializeShoukaku requires a client instance.");
|
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}`);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const shoukaku = new Shoukaku(new Connectors.DiscordJS(client), nodes, shoukakuOptions);
|
// --- Shoukaku Player Event Listeners ---
|
||||||
|
// REMOVED - These need to be attached differently in Shoukaku v4 (e.g., when player is created)
|
||||||
|
|
||||||
// --- Shoukaku Node Event Listeners ---
|
logger.info("Shoukaku instance created and node event listeners attached.");
|
||||||
shoukaku.on('ready', (name, resumed) =>
|
return shoukaku;
|
||||||
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,4 +1,4 @@
|
|||||||
import winston, { format, transports } from 'winston'; // Use ES6 import
|
import winston, { format, transports } from "winston"; // Use ES6 import
|
||||||
// No longer needed: import { TransformableInfo } from 'logform';
|
// No longer needed: import { TransformableInfo } from 'logform';
|
||||||
|
|
||||||
// Define the type for the log info object after timestamp is added
|
// Define the type for the log info object after timestamp is added
|
||||||
@ -8,24 +8,26 @@ import winston, { format, transports } from 'winston'; // Use ES6 import
|
|||||||
// };
|
// };
|
||||||
|
|
||||||
const logger = winston.createLogger({
|
const logger = winston.createLogger({
|
||||||
level: process.env.LOG_LEVEL || 'info', // Use LOG_LEVEL from env or default to 'info'
|
level: process.env.LOG_LEVEL || "info", // Use LOG_LEVEL from env or default to 'info'
|
||||||
format: format.combine(
|
format: format.combine(
|
||||||
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), // This adds the timestamp
|
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
|
format.printf((info: any) => {
|
||||||
// Ensure message exists, handle potential non-string messages if necessary
|
// Use 'any' for now to bypass strict type checking here
|
||||||
// The 'info' object structure depends on the preceding formatters
|
// Ensure message exists, handle potential non-string messages if necessary
|
||||||
const timestamp = info.timestamp || new Date().toISOString(); // Fallback if timestamp isn't added
|
// The 'info' object structure depends on the preceding formatters
|
||||||
const level = (info.level || 'info').toUpperCase();
|
const timestamp = info.timestamp || new Date().toISOString(); // Fallback if timestamp isn't added
|
||||||
const message = typeof info.message === 'string' ? info.message : JSON.stringify(info.message);
|
const level = (info.level || "info").toUpperCase();
|
||||||
return `${timestamp} ${level}: ${message}`;
|
const message =
|
||||||
})
|
typeof info.message === "string" ? info.message : JSON.stringify(info.message);
|
||||||
),
|
return `${timestamp} ${level}: ${message}`;
|
||||||
transports: [
|
}),
|
||||||
new transports.Console(),
|
),
|
||||||
// Optionally add file transport
|
transports: [
|
||||||
// new transports.File({ filename: 'combined.log' }),
|
new transports.Console(),
|
||||||
// new transports.File({ filename: 'error.log', level: 'error' }),
|
// 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
|
export default logger; // Use ES6 export default
|
||||||
|
|||||||
@ -1,37 +1,25 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
/* Base Options: */
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": ".",
|
||||||
|
"strict": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"target": "ES2022",
|
"forceConsistentCasingInFileNames": true,
|
||||||
"allowJs": true,
|
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"moduleDetection": "force",
|
"declaration": true,
|
||||||
"isolatedModules": true,
|
"moduleResolution": "node"
|
||||||
/* Strictness */
|
|
||||||
"strict": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"checkJs": true,
|
|
||||||
/* If NOT transpiling with TypeScript: */
|
|
||||||
"module": "NodeNext",
|
|
||||||
"noEmit": true,
|
|
||||||
/* If your code runs in the DOM: */
|
|
||||||
// "lib": ["es2022", "dom", "dom.iterable"],
|
|
||||||
/* If your code doesn't run in the DOM: */
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
|
|
||||||
/* If transpiling with TypeScript: */
|
|
||||||
"module": "CommonJS", // Use CommonJS for Node.js compatibility
|
|
||||||
"outDir": "dist", // Output compiled JS to dist/
|
|
||||||
"sourceMap": true, // Generate source maps
|
|
||||||
|
|
||||||
/* Project Structure */
|
|
||||||
// "rootDir": "src", // Remove rootDir as include covers files outside src
|
|
||||||
"baseUrl": ".", // Allows for path aliases if needed
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["src/*"] // Example path alias - keep if used, adjust if needed
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "deploy-commands.ts", "tests/**/*.ts"], // Include source, deploy script, and tests
|
"include": [
|
||||||
"exclude": ["node_modules", "dist"] // Exclude build output and dependencies
|
"src/**/*",
|
||||||
|
"deploy-commands.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user