diff --git a/README.md b/README.md index 2c46eec..2e17a08 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,8 @@ A Discord bot that simulates various animal behaviors in your server! Choose fro - Per-server configuration (each server can have its own theme) - Each theme has unique nicknames that the bot randomly assigns itself - Bot automatically changes its Discord nickname to match the theme +- Support for multiple servers simultaneously with independent configurations +- Can join voice channels in multiple servers at the same time ## Setup diff --git a/src/index.ts b/src/index.ts index d1bd709..b9f5d94 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { getRandomInt, randomChance } from './utils/random'; import { getRandomBehavior, getAudioFilePrefix } from './utils/bot-behaviors'; import { getRandomNickname, getTheme } from './utils/themes'; import { CatAudioPlayer } from './utils/audio-player'; +import { AudioPlayerManager } from './utils/audio-player-manager'; import { guildSettings } from './utils/guild-settings'; import { deployCommands } from './utils/command-deployer'; import { commandsMap } from './commands'; @@ -35,11 +36,11 @@ const client = new Client({ ] }); -// Initialize audio player -const audioPlayer = new CatAudioPlayer(); +// Initialize audio player manager +const audioPlayerManager = new AudioPlayerManager(); // Create status manager -const statusManager = new StatusManager(client); +const statusManager = new StatusManager(client, audioPlayerManager); // Map to track voice channel join timers const voiceJoinTimers = new Map(); @@ -343,7 +344,7 @@ function scheduleNextVoiceChannelJoin(guildId: string, initialChannel?: VoiceCha const audioFilePrefix = getAudioFilePrefix(guildId); // Join and play sounds - await audioPlayer.joinChannel( + await audioPlayerManager.joinChannel( chosenChannel, config.voiceChannelConfig.minStayDuration, config.voiceChannelConfig.maxStayDuration, @@ -377,3 +378,16 @@ function scheduleNextVoiceChannelJoin(guildId: string, initialChannel?: VoiceCha // Log in to Discord with your token client.login(config.token); + +// Handle graceful shutdown +process.on('SIGINT', () => { + console.log('Received SIGINT signal. Cleaning up...'); + audioPlayerManager.cleanup(); + process.exit(0); +}); + +process.on('SIGTERM', () => { + console.log('Received SIGTERM signal. Cleaning up...'); + audioPlayerManager.cleanup(); + process.exit(0); +}); diff --git a/src/utils/audio-player-manager.ts b/src/utils/audio-player-manager.ts new file mode 100644 index 0000000..0c2141e --- /dev/null +++ b/src/utils/audio-player-manager.ts @@ -0,0 +1,70 @@ +import { CatAudioPlayer } from './audio-player'; +import { VoiceChannel } from 'discord.js'; + +/** + * Manages audio players for multiple guilds + */ +export class AudioPlayerManager { + private audioPlayers: Map = new Map(); + + /** + * Get an audio player for a specific guild + * Creates a new one if it doesn't exist + */ + public getAudioPlayer(guildId: string): CatAudioPlayer { + if (!this.audioPlayers.has(guildId)) { + this.audioPlayers.set(guildId, new CatAudioPlayer()); + } + + return this.audioPlayers.get(guildId)!; + } + + /** + * Join a voice channel in a specific guild + */ + public async joinChannel( + voiceChannel: VoiceChannel, + minStayDuration: number, + maxStayDuration: number, + minMeowInterval: number, + maxMeowInterval: number, + audioFilePrefix: string = '' + ): Promise { + const guildId = voiceChannel.guild.id; + const audioPlayer = this.getAudioPlayer(guildId); + + await audioPlayer.joinChannel( + voiceChannel, + minStayDuration, + maxStayDuration, + minMeowInterval, + maxMeowInterval, + audioFilePrefix + ); + } + + /** + * Check if the bot is in a voice channel in any guild + */ + public isInAnyVoiceChannel(): boolean { + // Check all managed audio players + for (const audioPlayer of this.audioPlayers.values()) { + if (audioPlayer.isInVoiceChannel()) { + return true; + } + } + + return false; + } + + /** + * Clean up any resources when the bot is shutting down + */ + public cleanup(): void { + for (const audioPlayer of this.audioPlayers.values()) { + audioPlayer.cleanup(); + } + + this.audioPlayers.clear(); + } +} diff --git a/src/utils/audio-player.ts b/src/utils/audio-player.ts index 7add088..b5e50ed 100644 --- a/src/utils/audio-player.ts +++ b/src/utils/audio-player.ts @@ -6,7 +6,8 @@ import { getVoiceConnection, joinVoiceChannel, VoiceConnectionStatus, - NoSubscriberBehavior + NoSubscriberBehavior, + VoiceConnection } from '@discordjs/voice'; import { VoiceChannel } from 'discord.js'; import path from 'path'; @@ -19,6 +20,8 @@ export class CatAudioPlayer { private audioPlayer: AudioPlayer; private isPlaying: boolean = false; private shouldStop: boolean = false; + private currentVoiceChannel: VoiceChannel | null = null; + private currentConnection: VoiceConnection | null = null; constructor() { this.audioPlayer = createAudioPlayer({ @@ -31,6 +34,29 @@ export class CatAudioPlayer { this.isPlaying = false; }); } + + /** + * Check if this player is currently in a voice channel + */ + public isInVoiceChannel(): boolean { + return this.currentVoiceChannel !== null; + } + + /** + * Clean up resources + */ + public cleanup(): void { + if (this.currentConnection) { + try { + this.currentConnection.destroy(); + } catch (error) { + console.error('Error cleaning up voice connection:', error); + } + + this.currentVoiceChannel = null; + this.currentConnection = null; + } + } /** * Join a voice channel and start playing cat sounds @@ -44,6 +70,15 @@ export class CatAudioPlayer { audioFilePrefix: string = '' ): Promise { try { + // If already in a voice channel in this guild, leave it first + const existingConnection = getVoiceConnection(voiceChannel.guild.id); + if (existingConnection) { + existingConnection.destroy(); + } + + // Store the current voice channel + this.currentVoiceChannel = voiceChannel; + // Create connection - using Discord.js's built-in adapter // The @ts-ignore is required because of type compatibility issues between Discord.js and @discordjs/voice // This is a common issue and is safe to ignore in this case @@ -53,6 +88,9 @@ export class CatAudioPlayer { // @ts-ignore: Type compatibility issue between Discord.js and @discordjs/voice adapterCreator: voiceChannel.guild.voiceAdapterCreator, }); + + // Store the connection + this.currentConnection = connection; // Handle connection events connection.on(VoiceConnectionStatus.Disconnected, async () => { @@ -65,6 +103,8 @@ export class CatAudioPlayer { } catch (error) { // If we can't reconnect within 5 seconds, destroy the connection connection.destroy(); + this.currentVoiceChannel = null; + this.currentConnection = null; } }); @@ -77,7 +117,7 @@ export class CatAudioPlayer { // Determine how long the cat will stay const stayDuration = getRandomInt(minStayDuration, maxStayDuration) * 1000; - console.log(`Cat joined ${voiceChannel.name} for ${stayDuration / 1000} seconds`); + console.log(`Bot joined ${voiceChannel.name} in ${voiceChannel.guild.name} for ${stayDuration / 1000} seconds`); // Start playing sounds this.playRandomCatSounds(minMeowInterval, maxMeowInterval, stayDuration, audioFilePrefix); @@ -88,16 +128,19 @@ export class CatAudioPlayer { // Wait for any current sound to finish setTimeout(() => { - const connection = getVoiceConnection(voiceChannel.guild.id); - if (connection) { - connection.destroy(); - console.log(`Cat left ${voiceChannel.name}`); + if (this.currentConnection) { + this.currentConnection.destroy(); + console.log(`Bot left ${voiceChannel.name} in ${voiceChannel.guild.name}`); + this.currentVoiceChannel = null; + this.currentConnection = null; } }, 1000); }, stayDuration); } catch (error) { console.error('Error joining voice channel:', error); + this.currentVoiceChannel = null; + this.currentConnection = null; } } diff --git a/src/utils/status-manager.ts b/src/utils/status-manager.ts index 5ec0f27..3e84b67 100644 --- a/src/utils/status-manager.ts +++ b/src/utils/status-manager.ts @@ -2,13 +2,16 @@ import { ActivityType, Client } from 'discord.js'; import { config } from '../config/config'; import { getRandomElement } from './random'; import { getAvailableThemeIds, getTheme } from './themes'; +import { AudioPlayerManager } from './audio-player-manager'; export class StatusManager { private client: Client; + private audioPlayerManager: AudioPlayerManager; private statusUpdateInterval: NodeJS.Timeout | null = null; - constructor(client: Client) { + constructor(client: Client, audioPlayerManager: AudioPlayerManager) { this.client = client; + this.audioPlayerManager = audioPlayerManager; } /** @@ -46,17 +49,7 @@ export class StatusManager { * Check if the bot is currently in any voice channel */ private isInVoiceChannel(): boolean { - if (!this.client.guilds) return false; - - // Check if the bot is in a voice channel in any guild - for (const guild of this.client.guilds.cache.values()) { - const member = guild.members.cache.get(this.client.user?.id || ''); - if (member?.voice.channel) { - return true; - } - } - - return false; + return this.audioPlayerManager.isInAnyVoiceChannel(); } /**