All files / src/core BotLifecycleManager.js

82.87% Statements 150/181
89.47% Branches 34/38
71.42% Functions 20/28
84% Lines 147/175

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366            38x 38x 38x                 8x             8x 1x     8x 1x     8x 1x         8x 1x     8x 1x           8x 1x               7x 7x 7x 7x 7x 7x       7x 6x 6x 6x 6x       7x 7x   7x 7x   7x 1x 1x 2x         1x 1x 1x         7x 1x 1x 1x         7x       7x                                                                                                           5x 5x   5x 1x 1x         6x 4x   4x 5x   5x       5x 6x 5x 5x 3x 3x 3x 3x               5x 2x       4x               4x   4x 1x 1x         3x 3x 3x 3x 2x 1x     1x                   5x 5x 5x 5x   5x 5x 1x 1x     4x   4x 4x 1x 1x 1x 1x   1x   3x     3x 3x 1x 1x     2x 2x 1x 1x 1x   1x   1x 1x 1x   1x 1x     1x 1x                   4x 1x 1x     3x   3x     3x 3x     3x     3x 3x   1x 1x     3x 3x         2x             4x   4x 4x 1x 1x   3x   3x 3x 1x 2x 1x   2x   2x 2x 2x   2x   2x 2x 1x   1x 1x         4x
/**
 * BotLifecycleManager - Handles bot startup, ready events, and shutdown sequences
 * Manages Discord event handler setup, message caching, and deployment notifications
 */
class BotLifecycleManager {
    constructor(bot) {
        this.bot = bot;
        this.client = bot.client;
        this.shutdownMessageSent = false;
    }
 
    /**
     * Setup Discord event handlers
     */
    setupEventHandlers() {
        // 'ready' event fires once when bot successfully connects and is operational
        // This is the ideal place for startup tasks and status verification
        this.client.once('ready', async () => {
            await this.handleReady();
        });
 
        // Core Discord event handlers - delegate to specialized modules for processing
        // This keeps the main bot class focused on coordination rather than implementation
        
        this.client.on('messageReactionAdd', (reaction, user) => {
            this.bot.eventHandlers.handleReactionAdd(reaction, user);
        });
 
        this.client.on('messageReactionRemove', (reaction, user) => {
            this.bot.eventHandlers.handleReactionRemove(reaction, user);
        });
 
        this.client.on('messageCreate', (message) => {
            this.bot.eventHandlers.handleMessage(message);
        });
 
        // Error handling for Discord connection issues
        // These events help track bot health and connectivity problems
        this.client.on('error', (error) => {
            console.error('Discord client error:', error);
        });
 
        this.client.on('warn', (warning) => {
            console.warn('Discord client warning:', warning);
        });
 
        // Graceful shutdown handlers for clean deployments
        // These ensure the bot disconnects properly and doesn't leave hanging connections
        // Skip in test environment to prevent MaxListenersExceededWarning
        if (process.env.NODE_ENV !== 'test') {
            this.setupGracefulShutdown();
        }
    }
 
    /**
     * Handle bot ready event
     */
    async handleReady() {
        console.log(`Bot logged in as ${this.client.user.tag}`);
        console.log(`Monitoring guild: ${this.bot.getGuildId()}`);
        console.log(`Moderator command channel: ${this.bot.getCommandChannelId()}`);
        console.log(`Member command channel: ${this.bot.getMemberCommandChannelId()}`);
        console.log(`Moderator role ID: ${this.bot.getModeratorRoleId()}`);
        console.log(`Member role ID: ${this.bot.getMemberRoleId()}`);
        
        // Display current proposal system configuration for verification
        // This helps administrators confirm the bot is monitoring the right channels
        if (this.bot.proposalManager.proposalConfig) {
            console.log('Proposal types configured:');
            Object.entries(this.bot.proposalManager.proposalConfig).forEach(([type, config]) => {
                console.log(`  ${type}: ${config.supportThreshold} reactions, ${config.voteDuration}ms duration`);
                console.log(`    Debate: ${config.debateChannelId}, Vote: ${config.voteChannelId}, Resolutions: ${config.resolutionsChannelId}`);
            });
        }
        
        const currentConfig = this.bot.configManager.getConfig();
        console.log(`Reaction configurations loaded: ${currentConfig.length}`);
        
        const activeVotes = await this.bot.proposalManager.getActiveVotes();
        console.log(`Active votes: ${activeVotes.length}`);
        
        if (currentConfig.length > 0) {
            console.log('Current reaction configurations:');
            currentConfig.forEach((cfg, index) => {
                console.log(`  ${index + 1}: Message ${cfg.from}, Action ${cfg.action}`);
            });
            
            // Pre-cache messages to ensure reaction events work immediately
            // Discord requires messages to be cached before reaction events fire reliably
            console.log('šŸ”„ Pre-caching reaction messages...');
            await this.preCacheMessages(currentConfig);
            console.log('āœ… Message pre-caching completed');
        }
 
        // Pre-cache active vote messages to ensure voting continues properly
        // This is crucial for maintaining voting integrity across bot restarts
        if (activeVotes.length > 0) {
            console.log('šŸ”„ Pre-caching active vote messages...');
            await this.preCacheVoteMessages(activeVotes);
            console.log('āœ… Vote message pre-caching completed');
        }
 
        // Post deployment confirmation message to moderator bot channel
        // Helps confirm new deployments are successful and bot is fully operational
        await this.postDeploymentConfirmation();
 
        // Signal to enhanced wrapper that bot is fully ready
        // This triggers health check readiness and deployment completion
        process.emit('botReady');
    }
 
    /**
     * Setup graceful shutdown handlers
     */
    setupGracefulShutdown() {
        process.on('SIGINT', async () => {
            console.log('Shutting down...');
            await this.postShutdownMessage('Manual shutdown (SIGINT)');
            this.client.destroy();
            process.exit(0);
        });
 
        process.on('SIGTERM', async () => {
            console.log('šŸ”„ Received SIGTERM, shutting down...');
            await this.postShutdownMessage('Instance termination (SIGTERM)');
            this.client.destroy();
            process.exit(0);
        });
 
        // Consolidated ALB draining detection - all paths lead to this handler
        let drainingHandled = false;
        const handleDraining = async (source) => {
            const timestamp = new Date().toISOString();
            console.log(`šŸ”„ [${timestamp}] ALB draining signal received from ${source}`);
            if (drainingHandled) {
                console.log(`šŸ”„ ALB draining already handled, ignoring ${source} signal`);
                return;
            }
            drainingHandled = true;
            console.log(`šŸ”„ ALB draining detected from ${source}, sending shutdown message...`);
            
            try {
                await this.postShutdownMessage('Instance draining (ALB health checks stopped)');
                console.log(`āœ… Shutdown message sent successfully from ${source}`);
            } catch (error) {
                console.error(`āŒ Failed to send shutdown message from ${source}:`, error);
            }
            // Don't destroy client yet, just send the message
        };
 
        // Multiple ways the draining can be detected, but one handler
        process.on('earlyShutdown', () => handleDraining('earlyShutdown'));
        process.on('SIGUSR1', () => handleDraining('SIGUSR1'));
        process.on('albDraining', () => handleDraining('albDraining'));
    }
 
    /**
     * Pre-cache messages that we're monitoring for reactions
     * Discord requires messages to be in cache before reaction events will fire properly
     * This prevents missed reactions on bot restart and ensures reliable role assignment
     */
    async preCacheMessages(config) {
        console.log('šŸ”„ Pre-caching monitored messages...');
        const guild = this.client.guilds.cache.get(this.bot.getGuildId());
        
        if (!guild) {
            console.log('āŒ Guild not found for pre-caching');
            return;
        }
 
        // Extract unique message IDs to avoid fetching the same message multiple times
        // Multiple configs might reference the same message with different reactions
        const uniqueMessageIds = [...new Set(config.map(cfg => cfg.from))];
        console.log(`Found ${uniqueMessageIds.length} unique messages to cache from ${config.length} configs`);
 
        for (const messageId of uniqueMessageIds) {
            let messageFound = false;
 
            console.log(`šŸ” Searching for message ${messageId}...`);
 
            // Search through all text channels since we don't know which channel contains each message
            // This brute-force approach is necessary because message IDs don't encode channel information
            for (const [channelId, channel] of guild.channels.cache) {
                if (channel.isTextBased()) {
                    try {
                        const message = await channel.messages.fetch(messageId);
                        Eif (message) {
                            console.log(`āœ… Cached message ${messageId} from #${channel.name}`);
                            messageFound = true;
                            break;
                        }
                    } catch (err) {
                        // Message not in this channel, continue searching
                    }
                }
            }
 
            if (!messageFound) {
                console.log(`āš ļø  Message ${messageId} not found in any channel`);
            }
        }
        
        console.log('āœ… Pre-caching complete');
    }
 
    /**
     * Pre-cache active vote messages to maintain voting functionality across restarts
     * Vote messages must be cached for reaction counting and vote processing to work properly
     */
    async preCacheVoteMessages(activeVotes) {
        const guild = this.client.guilds.cache.get(this.bot.getGuildId());
        
        if (!guild) {
            console.log('āŒ Guild not found for vote pre-caching');
            return;
        }
 
        // Cache each active vote message so the bot can continue processing votes
        // This is essential for democracy to function across bot deployments
        for (const vote of activeVotes) {
            try {
                const voteChannel = guild.channels.cache.get(vote.voteChannelId);
                if (voteChannel) {
                    await voteChannel.messages.fetch(vote.voteMessageId);
                    console.log(`āœ… Cached vote message ${vote.voteMessageId}`);
                }
            } catch (error) {
                console.log(`āš ļø  Could not cache vote message ${vote.voteMessageId}`);
            }
        }
    }
 
    /**
     * Post a brief confirmation message to moderator bot channel after successful startup
     * Provides sanity check that new deployment is online and fully operational
     */
    async postDeploymentConfirmation() {
        try {
            console.log(`šŸ” Attempting to post deployment confirmation...`);
            console.log(`šŸ” Guild ID: ${this.bot.getGuildId()}`);
            console.log(`šŸ” Command Channel ID: ${this.bot.getCommandChannelId()}`);
            
            const guild = this.client.guilds.cache.get(this.bot.getGuildId());
            if (!guild) {
                console.error(`āŒ Guild not found for deployment confirmation. Guild ID: ${this.bot.getGuildId()}`);
                console.error(`āŒ Available guilds: ${Array.from(this.client.guilds.cache.keys()).join(', ')}`);
                return;
            }
            console.log(`āœ… Found guild: ${guild.name} (${guild.id})`);
 
            const modBotChannel = guild.channels.cache.get(this.bot.getCommandChannelId());
            if (!modBotChannel) {
                console.error(`āŒ Moderator bot channel not found. Channel ID: ${this.bot.getCommandChannelId()}`);
                console.error(`āŒ Available channels in guild:`);
                guild.channels.cache.forEach(channel => {
                    console.error(`   - ${channel.name} (${channel.id}) - Type: ${channel.type}`);
                });
                return;
            }
            console.log(`āœ… Found channel: ${modBotChannel.name} (${modBotChannel.id})`);
 
            // Check bot permissions in the channel
            const botMember = guild.members.cache.get(this.client.user.id);
            if (!botMember) {
                console.error(`āŒ Bot member not found in guild`);
                return;
            }
            
            const permissions = modBotChannel.permissionsFor(botMember);
            if (!permissions.has('SendMessages')) {
                console.error(`āŒ Bot does not have SendMessages permission in ${modBotChannel.name}`);
                console.error(`āŒ Bot permissions: ${permissions.toArray().join(', ')}`);
                return;
            }
            console.log(`āœ… Bot has SendMessages permission in ${modBotChannel.name}`);
 
            const timestamp = new Date().toISOString();
            const startupTime = Math.round(process.uptime());
            const confirmationMessage = `šŸ¤– **Bot ${this.bot.getRunId()} Online** - New version deployed and ready\nā° ${timestamp}\n⚔ Startup: ${startupTime}s`;
 
            await modBotChannel.send(confirmationMessage);
            console.log(`āœ… Posted deployment confirmation to ${modBotChannel.name} channel`);
            
        } catch (error) {
            console.error('āŒ Error posting deployment confirmation:', error);
            console.error('āŒ Error stack:', error.stack);
        }
    }
 
    /**
     * Post a brief shutdown message to moderator bot channel before terminating
     * Provides visibility when instances are being replaced during deployments
     */
    async postShutdownMessage(reason) {
        // Prevent duplicate shutdown messages
        if (this.shutdownMessageSent) {
            console.log(`šŸ”„ Shutdown message already sent, skipping duplicate for: ${reason}`);
            return;
        }
        
        console.log(`šŸ”„ Attempting to send shutdown message for: ${reason}`);
        
        try {
            // Set a timeout to ensure we don't block shutdown too long
            let timeoutId;
            const timeoutPromise = new Promise((_, reject) => 
                timeoutId = setTimeout(() => reject(new Error('Shutdown message timeout')), 5000)
            );
 
            const messagePromise = this.sendShutdownMessage(reason);
            
            // Race between sending message and timeout
            try {
                await Promise.race([messagePromise, timeoutPromise]);
                // Only set flag after successful send
                this.shutdownMessageSent = true;
                console.log(`āœ… Shutdown message sent successfully for: ${reason}`);
            } finally {
                // Always clear the timeout to prevent Jest hanging
                Eif (timeoutId) {
                    clearTimeout(timeoutId);
                }
            }
            
        } catch (error) {
            console.error(`āŒ Error posting shutdown message for ${reason}:`, error);
            // Don't set the flag if sending failed, so we can retry
            // Don't block shutdown even if message fails
        }
    }
 
    async sendShutdownMessage(reason) {
        console.log(`šŸ”„ sendShutdownMessage called with reason: ${reason}`);
        
        const guild = this.client.guilds.cache.get(this.bot.getGuildId());
        if (!guild) {
            console.log(`āŒ Guild not found for shutdown message. Guild ID: ${this.bot.getGuildId()}`);
            return;
        }
        console.log(`āœ… Found guild: ${guild.name} (${guild.id})`);
 
        const modBotChannel = guild.channels.cache.get(this.bot.getCommandChannelId());
        if (!modBotChannel) {
            console.log(`āŒ Moderator bot channel ${this.bot.getCommandChannelId()} not found`);
            console.log(`āŒ Available channels: ${Array.from(guild.channels.cache.values()).map(ch => `${ch.name}(${ch.id})`).join(', ')}`);
            return;
        }
        console.log(`āœ… Found moderator channel: ${modBotChannel.name} (${modBotChannel.id})`);
 
        const timestamp = new Date().toISOString();
        const uptime = Math.round(process.uptime());
        const shutdownMessage = `šŸ”„ **Bot ${this.bot.getRunId()} Shutting Down** - ${reason}\nā° ${timestamp}\n⚔ Uptime: ${uptime}s`;
 
        console.log(`šŸ”„ Attempting to send shutdown message: ${shutdownMessage.replace(/\n/g, ' | ')}`);
        
        try {
            await modBotChannel.send(shutdownMessage);
            console.log(`āœ… Posted shutdown message to moderator bot channel successfully`);
        } catch (error) {
            console.error(`āŒ Failed to send shutdown message to channel:`, error);
            throw error; // Re-throw so the calling function knows it failed
        }
    }
}
 
module.exports = BotLifecycleManager;