refactor: Enhance voice channel joining logic with retry mechanism and cleanup for existing players

This commit is contained in:
Jose Daniel G. Percy 2025-04-25 01:22:43 +08:00
parent 1aa97a8a7a
commit a2c9121012
2 changed files with 113 additions and 89 deletions

View File

@ -2,24 +2,21 @@ import {
SlashCommandBuilder, SlashCommandBuilder,
PermissionFlagsBits, PermissionFlagsBits,
ChannelType, ChannelType,
ChatInputCommandInteraction, // Import the specific _interaction type ChatInputCommandInteraction,
GuildMember, // Import GuildMember type GuildMember,
VoiceBasedChannel, // Import VoiceBasedChannel type VoiceBasedChannel,
} from "discord.js"; } from "discord.js";
import logger from "../utils/logger.js"; // Use default import import logger from "../utils/logger.js";
import { BotClient } from "../index.js"; // Import the BotClient interface import { BotClient } from "../index.js";
import { Player } from "shoukaku"; // Import the Player type explicitly import { Player } from "shoukaku";
export default { export default {
// Use export default for ES Modules
data: new SlashCommandBuilder() data: new SlashCommandBuilder()
.setName("join") .setName("join")
.setDescription("Joins your current voice channel"), .setDescription("Joins your current voice channel"),
async execute(_interaction: ChatInputCommandInteraction, _client: BotClient) { async execute(_interaction: ChatInputCommandInteraction, _client: BotClient) {
// Add types
// Ensure command is run in a guild // Ensure command is run in a guild
if (!_interaction.guildId || !_interaction.guild || !_interaction.channelId) { if (!_interaction.guildId || !_interaction.guild || !_interaction.channelId) {
// Reply might fail if _interaction is already replied/deferred, use editReply if needed
return _interaction return _interaction
.reply({ content: "This command can only be used in a server.", ephemeral: true }) .reply({ content: "This command can only be used in a server.", ephemeral: true })
.catch(() => {}); .catch(() => {});
@ -46,9 +43,8 @@ export default {
const currentVoiceChannel = voiceChannel as VoiceBasedChannel; const currentVoiceChannel = voiceChannel as VoiceBasedChannel;
// 2. Check bot permissions // 2. Check bot permissions
const permissions = currentVoiceChannel.permissionsFor(_client.user!); // Use non-null assertion for _client.user const permissions = currentVoiceChannel.permissionsFor(_client.user!);
if (!permissions?.has(PermissionFlagsBits.Connect)) { if (!permissions?.has(PermissionFlagsBits.Connect)) {
// Optional chaining for permissions
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)) {
@ -67,77 +63,65 @@ export default {
} }
// 3. Get or create the player and connect using Shoukaku // 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); let player: Player | undefined = shoukaku.players.get(_interaction.guildId);
if (!player) { // First, ensure clean state by disconnecting if already connected
if (player) {
try { try {
// Create player using the Shoukaku manager logger.info(`Destroying existing player for guild ${_interaction.guildId} before reconnecting`);
await player.destroy();
player = undefined;
} catch (error) {
logger.warn(`Error destroying existing player: ${error}`);
// Continue with connection attempt anyway
}
}
// Attempt to join voice channel with retry logic
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
attempts++;
try {
// Wait a short time between retries to allow Discord's voice state to update
if (attempts > 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
logger.info(`Attempt ${attempts} to join voice channel ${currentVoiceChannel.id}`);
}
player = await shoukaku.joinVoiceChannel({ player = await shoukaku.joinVoiceChannel({
guildId: _interaction.guildId, guildId: _interaction.guildId,
channelId: currentVoiceChannel.id, channelId: currentVoiceChannel.id,
shardId: _interaction.guild.shardId, // Get shardId from guild shardId: _interaction.guild.shardId,
deaf: true // Set to true to avoid listening to voice data, saves bandwidth
}); });
logger.info( logger.info(
`Created player and connected to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${_interaction.guild.name} (${_interaction.guildId})`, `Created player and connected to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) in guild ${_interaction.guild.name} (${_interaction.guildId})`,
); );
// Connection was successful
await _interaction.editReply(`Joined ${currentVoiceChannel.name}! Ready to play music.`); await _interaction.editReply(`Joined ${currentVoiceChannel.name}! Ready to play music.`);
return;
} catch (error: unknown) { } catch (error: unknown) {
// Type error as unknown
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
logger.error( logger.error(
`Failed to create/connect player for guild ${_interaction.guildId}: ${errorMessage}`, `Attempt ${attempts}: Failed to connect to voice channel for guild ${_interaction.guildId}: ${errorMessage}`,
error, error,
); );
// Attempt to leave voice channel if connection failed partially
shoukaku.leaveVoiceChannel(_interaction.guildId).catch((e: unknown) => { // Clean up any partial connections on failure
// 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 { try {
// Rejoining should handle moving the bot await shoukaku.leaveVoiceChannel(_interaction.guildId);
// Note: joinVoiceChannel might implicitly destroy the old player/connection if one exists for the guild. } catch (leaveError) {
// If issues arise, explicitly call leaveVoiceChannel first. // Ignore leave errors
player = await shoukaku.joinVoiceChannel({ }
guildId: _interaction.guildId,
channelId: currentVoiceChannel.id, if (attempts === maxAttempts) {
shardId: _interaction.guild.shardId, return _interaction.editReply(`Failed to join voice channel after ${maxAttempts} attempts. Please try again later.`);
});
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}`);
// }
} }
}, },
}; };

View File

@ -180,34 +180,75 @@ export default {
player = shoukaku.players.get(_interaction.guildId) as GuildPlayer | undefined; player = shoukaku.players.get(_interaction.guildId) as GuildPlayer | undefined;
const connection = shoukaku.connections.get(_interaction.guildId); const connection = shoukaku.connections.get(_interaction.guildId);
// Check if we need to join or move to a different channel
if (!player || !connection || connection.channelId !== currentVoiceChannel.id) { if (!player || !connection || connection.channelId !== currentVoiceChannel.id) {
// If player/connection doesn't exist or bot is in wrong channel, join/move // If existing player, destroy it for a clean slate
try { if (player) {
player = (await shoukaku.joinVoiceChannel({ try {
guildId: _interaction.guildId, logger.info(`Destroying existing player for guild ${_interaction.guildId} before reconnecting`);
channelId: currentVoiceChannel.id, await player.destroy();
shardId: _interaction.guild.shardId, player = undefined;
})) as GuildPlayer; // Cast to extended type } catch (error) {
logger.info( logger.warn(`Error destroying existing player: ${error}`);
`Joined/Moved to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) for play command.`, // Continue with connection attempt anyway
); }
// Initialize queue if it's a new player }
if (!player.queue) {
player.queue = []; // Attempt to join voice channel with retry logic
let attempts = 0;
const maxAttempts = 3;
let joinSuccess = false;
while (attempts < maxAttempts && !joinSuccess) {
attempts++;
try {
// Wait a short time between retries to allow Discord's voice state to update
if (attempts > 1) {
await new Promise(resolve => setTimeout(resolve, 1000));
logger.info(`Attempt ${attempts} to join voice channel ${currentVoiceChannel.id}`);
}
player = (await shoukaku.joinVoiceChannel({
guildId: _interaction.guildId,
channelId: currentVoiceChannel.id,
shardId: _interaction.guild.shardId,
deaf: true // Set to true to avoid listening to voice data, saves bandwidth
})) as GuildPlayer;
// Initialize queue if it's a new player
if (!player.queue) {
player.queue = [];
}
player.textChannelId = _interaction.channelId; // Store text channel context
logger.info(
`Joined/Moved to voice channel ${currentVoiceChannel.name} (${currentVoiceChannel.id}) for play command.`,
);
joinSuccess = true;
} catch (joinError: unknown) {
const errorMsg = joinError instanceof Error ? joinError.message : String(joinError);
logger.error(
`Attempt ${attempts}: Failed to join voice channel for guild ${_interaction.guildId}: ${errorMsg}`,
joinError,
);
// Clean up any partial connections on failure
try {
await shoukaku.leaveVoiceChannel(_interaction.guildId);
} catch (leaveError) {
// Ignore leave errors
}
if (attempts === maxAttempts) {
return _interaction.editReply(
"Failed to join the voice channel after multiple attempts. Please try again later."
);
}
} }
player.textChannelId = _interaction.channelId; // Store text channel context
} catch (joinError: unknown) {
const errorMsg = joinError instanceof Error ? joinError.message : String(joinError);
logger.error(
`Failed to join/move player for guild ${_interaction.guildId}: ${errorMsg}`,
joinError,
);
shoukaku.leaveVoiceChannel(_interaction.guildId).catch(() => {}); // Attempt cleanup
return _interaction.editReply(
"An error occurred while trying to join the voice channel.",
);
} }
} else { } else {
// We already have a player connected to the right channel
// Ensure queue exists if player was retrieved // Ensure queue exists if player was retrieved
if (!player.queue) { if (!player.queue) {
player.queue = []; player.queue = [];
@ -271,10 +312,9 @@ export default {
responseEmbed responseEmbed
.setTitle("Track Added to Queue") .setTitle("Track Added to Queue")
.setDescription(`[${track.info.title}](${track.info.uri})`) .setDescription(`[${track.info.title}](${track.info.uri})`)
// Ensure player exists before accessing queue
.addFields({ .addFields({
name: "Position in queue", name: "Position in queue",
value: `${player.queue.length + 1}`, value: `${player?.queue?.length ?? 0 + 1}`, // Add null checks
inline: true, inline: true,
}); });
if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl); // Use artworkUrl if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl); // Use artworkUrl
@ -298,7 +338,7 @@ export default {
.setDescription(`[${track.info.title}](${track.info.uri})`) .setDescription(`[${track.info.title}](${track.info.uri})`)
.addFields({ .addFields({
name: "Position in queue", name: "Position in queue",
value: `${player.queue.length + 1}`, value: `${player?.queue?.length ?? 0 + 1}`, // Add null checks
inline: true, inline: true,
}); });
if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl); if (track.info.artworkUrl) responseEmbed.setThumbnail(track.info.artworkUrl);