import TelegramBot from 'node-telegram-bot-api'; import { groupStateManager } from './groupState'; import { stateManager } from './state'; import { apiClient } from './api'; import { logger } from './logger'; import { messages } from '../messages'; import { GroupSettings, reminderTimeToMinutes, formatReminderTime, ReminderTime, DEFAULT_GROUP_REMINDER_SLOTS } from '../types/groups'; import { TelegramUser } from '../types'; interface CycleInfo { id: string; scheduled_at: string; status: string; pot_total_sats: number; } interface ScheduledReminder { groupId?: number; telegramId?: number; cycleId: string; reminderKey: string; scheduledFor: Date; timeout: NodeJS.Timeout; } class NotificationScheduler { private bot: TelegramBot | null = null; private pollInterval: NodeJS.Timeout | null = null; private scheduledReminders: Map = new Map(); private lastCycleId: string | null = null; private lastCycleStatus: string | null = null; private isRunning = false; private announcedCycles: Set = new Set(); // Track announced cycles /** * Initialize the scheduler with the bot instance */ init(bot: TelegramBot): void { this.bot = bot; logger.info('Notification scheduler initialized'); } /** * Start the scheduler */ start(): void { if (this.isRunning || !this.bot) { return; } this.isRunning = true; logger.info('Starting notification scheduler'); // Poll every 30 seconds this.pollInterval = setInterval(() => this.poll(), 30 * 1000); // Run immediately this.poll(); } /** * Stop the scheduler */ stop(): void { if (this.pollInterval) { clearInterval(this.pollInterval); this.pollInterval = null; } // Clear all scheduled reminders for (const reminder of this.scheduledReminders.values()) { clearTimeout(reminder.timeout); } this.scheduledReminders.clear(); this.isRunning = false; logger.info('Notification scheduler stopped'); } /** * Main poll loop */ private async poll(): Promise { try { const jackpot = await apiClient.getNextJackpot(); if (!jackpot?.cycle) { return; } const cycle = jackpot.cycle; const lottery = jackpot.lottery; // Check for draw completion (same cycle, status changed to completed) if (this.lastCycleId === cycle.id && this.lastCycleStatus !== 'completed' && cycle.status === 'completed') { await this.handleDrawCompleted(cycle); } // Check for new cycle (new jackpot started) // IMPORTANT: When we detect a new cycle, the old one is completed // Send draw completion for the old cycle BEFORE new cycle announcement if (this.lastCycleId && this.lastCycleId !== cycle.id) { // The previous cycle has completed - announce the draw result first await this.handlePreviousCycleCompleted(this.lastCycleId); // Then announce the new cycle await this.handleNewCycle(cycle, lottery); } // Schedule reminders for current cycle await this.scheduleGroupReminders(cycle); await this.scheduleUserReminders(cycle); this.lastCycleId = cycle.id; this.lastCycleStatus = cycle.status; } catch (error) { logger.error('Error in notification scheduler poll', { error }); } } /** * Handle previous cycle completion when we detect a new cycle */ private async handlePreviousCycleCompleted(previousCycleId: string): Promise { if (!this.bot) return; // Check if we've already announced this draw if (this.announcedCycles.has(`draw:${previousCycleId}`)) { return; } this.announcedCycles.add(`draw:${previousCycleId}`); // Clear reminders for the old cycle this.clearRemindersForCycle(previousCycleId); // Get participants for the previous cycle const participants = await stateManager.getCycleParticipants(previousCycleId); const hasParticipants = participants.length > 0; logger.info('Processing previous cycle completion', { cycleId: previousCycleId, participantCount: participants.length }); // Get draw result details for winner announcement let winnerDisplayName = 'Anon'; let winnerTicketNumber = '0000'; let potSats = 0; // Notify each participant about their result for (const participant of participants) { try { const user = await stateManager.getUser(participant.telegramId); if (!user) continue; // Check if they won const status = await apiClient.getTicketStatus(participant.purchaseId); if (!status) continue; potSats = status.cycle.pot_total_sats || 0; const isWinner = status.result.is_winner; if (isWinner) { // Store winner info for group announcement winnerDisplayName = user.displayName || 'Anon'; const winningTicket = status.tickets.find(t => t.is_winning_ticket); if (winningTicket) { winnerTicketNumber = winningTicket.serial_number.toString().padStart(4, '0'); } // Send winner notification if user has drawResults enabled if (user.notifications?.drawResults !== false) { const prizeSats = status.result.payout?.amount_sats || potSats; const payoutStatus = status.result.payout?.status || 'processing'; await this.bot.sendMessage( participant.telegramId, messages.notifications.winner( prizeSats.toLocaleString(), winnerTicketNumber, payoutStatus ), { parse_mode: 'Markdown' } ); logger.info('Sent winner notification', { telegramId: participant.telegramId }); } } else { // Send loser notification if user has drawResults enabled if (user.notifications?.drawResults !== false) { await this.bot.sendMessage( participant.telegramId, messages.notifications.loser( winnerTicketNumber, potSats.toLocaleString() ), { parse_mode: 'Markdown' } ); logger.debug('Sent draw result to participant', { telegramId: participant.telegramId }); } } } catch (error) { logger.error('Failed to notify participant', { telegramId: participant.telegramId, error }); } } // Send group announcements (even if no participants - groups might want to know) if (hasParticipants) { await this.sendGroupDrawAnnouncementsImmediate( previousCycleId, winnerDisplayName, winnerTicketNumber, potSats, participants.length ); } } /** * Send draw announcements to groups immediately (no delay - for cycle transition) */ private async sendGroupDrawAnnouncementsImmediate( cycleId: string, winnerDisplayName: string, winnerTicketNumber: string, potSats: number, totalParticipants: number ): Promise { if (!this.bot) return; const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements'); for (const group of groups) { try { const message = messages.notifications.drawAnnouncement( winnerDisplayName, `#${winnerTicketNumber}`, potSats.toLocaleString(), totalParticipants ); await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' }); logger.debug('Sent draw announcement to group', { groupId: group.groupId }); } catch (error) { this.handleSendError(error, group.groupId); } } logger.info('Draw announcements sent', { cycleId, groupCount: groups.length }); } /** * Handle new cycle announcement */ private async handleNewCycle( cycle: CycleInfo, lottery: { name: string; ticket_price_sats: number } ): Promise { if (!this.bot) return; // Check if we've already announced this cycle if (this.announcedCycles.has(`new:${cycle.id}`)) { return; } this.announcedCycles.add(`new:${cycle.id}`); const drawTime = new Date(cycle.scheduled_at); const bot = this.bot; // Send to groups (with configurable delay) const groups = await groupStateManager.getGroupsWithFeature('enabled'); for (const group of groups) { if (group.newJackpotAnnouncement === false) continue; const delayMs = (group.newJackpotDelayMinutes ?? 5) * 60 * 1000; setTimeout(async () => { try { const message = messages.notifications.newJackpot( lottery.name, lottery.ticket_price_sats, drawTime ); await bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' }); logger.debug('Sent new jackpot announcement to group', { groupId: group.groupId, delayMinutes: group.newJackpotDelayMinutes ?? 5 }); } catch (error) { this.handleSendError(error, group.groupId); } }, delayMs); } // Send to users with new jackpot alerts enabled (immediate) const users = await stateManager.getUsersWithNotification('newJackpotAlerts'); for (const user of users) { try { const message = messages.notifications.newJackpot( lottery.name, lottery.ticket_price_sats, drawTime ); await this.bot.sendMessage(user.telegramId, message, { parse_mode: 'Markdown' }); logger.debug('Sent new jackpot alert to user', { telegramId: user.telegramId }); } catch (error) { logger.error('Failed to send new jackpot alert', { telegramId: user.telegramId, error }); } } logger.info('New jackpot announcements scheduled/sent', { cycleId: cycle.id }); } /** * Handle draw completed - send notifications to participants and groups */ private async handleDrawCompleted(cycle: CycleInfo): Promise { if (!this.bot) return; // Check if we've already announced this draw if (this.announcedCycles.has(`draw:${cycle.id}`)) { return; } this.announcedCycles.add(`draw:${cycle.id}`); // Clear reminders for this cycle this.clearRemindersForCycle(cycle.id); // Get participants for this cycle const participants = await stateManager.getCycleParticipants(cycle.id); const hasParticipants = participants.length > 0; logger.info('Processing draw completion', { cycleId: cycle.id, participantCount: participants.length, potSats: cycle.pot_total_sats }); // Only proceed if there were participants if (!hasParticipants) { logger.info('No participants in cycle, skipping notifications', { cycleId: cycle.id }); return; } // Get draw result details for winner announcement let winnerDisplayName = 'Anon'; let winnerTicketNumber = '0000'; // Notify each participant about their result for (const participant of participants) { try { const user = await stateManager.getUser(participant.telegramId); if (!user || !user.notifications?.drawResults) continue; // Check if they won const status = await apiClient.getTicketStatus(participant.purchaseId); if (!status) continue; const isWinner = status.result.is_winner; if (isWinner) { // Store winner info for group announcement winnerDisplayName = user.displayName || 'Anon'; const winningTicket = status.tickets.find(t => t.is_winning_ticket); if (winningTicket) { winnerTicketNumber = winningTicket.serial_number.toString().padStart(4, '0'); } // Send winner notification const prizeSats = status.result.payout?.amount_sats || cycle.pot_total_sats; const payoutStatus = status.result.payout?.status || 'processing'; await this.bot.sendMessage( participant.telegramId, messages.notifications.winner( prizeSats.toLocaleString(), winnerTicketNumber, payoutStatus ), { parse_mode: 'Markdown' } ); logger.info('Sent winner notification', { telegramId: participant.telegramId }); } else { // Send loser notification await this.bot.sendMessage( participant.telegramId, messages.notifications.loser( winnerTicketNumber, cycle.pot_total_sats.toLocaleString() ), { parse_mode: 'Markdown' } ); logger.debug('Sent draw result to participant', { telegramId: participant.telegramId }); } } catch (error) { logger.error('Failed to notify participant', { telegramId: participant.telegramId, error }); } } // Send group announcements (only if there were participants) await this.sendGroupDrawAnnouncements(cycle, winnerDisplayName, winnerTicketNumber, participants.length); } /** * Send draw announcements to groups */ private async sendGroupDrawAnnouncements( cycle: CycleInfo, winnerDisplayName: string, winnerTicketNumber: string, totalParticipants: number ): Promise { if (!this.bot) return; const groups = await groupStateManager.getGroupsWithFeature('drawAnnouncements'); for (const group of groups) { const delay = (group.announcementDelaySeconds || 0) * 1000; setTimeout(async () => { try { const message = messages.notifications.drawAnnouncement( winnerDisplayName, `#${winnerTicketNumber}`, cycle.pot_total_sats.toLocaleString(), totalParticipants ); if (this.bot) { await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' }); logger.debug('Sent draw announcement to group', { groupId: group.groupId }); } } catch (error) { this.handleSendError(error, group.groupId); } }, delay); } logger.info('Draw announcements scheduled', { cycleId: cycle.id, groupCount: groups.length }); } /** * Schedule reminders for groups (3-tier system with custom times) */ private async scheduleGroupReminders(cycle: CycleInfo): Promise { if (!this.bot || cycle.status === 'completed' || cycle.status === 'cancelled') { return; } const drawTime = new Date(cycle.scheduled_at); const now = new Date(); const groups = await groupStateManager.getGroupsWithFeature('reminders'); for (const group of groups) { // Build list of enabled reminders from 3-tier system with custom times const enabledReminders: { slot: number; time: ReminderTime }[] = []; // Check each of the 3 reminder slots with their custom times if (group.reminder1Enabled !== false) { const time = group.reminder1Time || DEFAULT_GROUP_REMINDER_SLOTS[0]; enabledReminders.push({ slot: 1, time }); } if (group.reminder2Enabled === true) { const time = group.reminder2Time || DEFAULT_GROUP_REMINDER_SLOTS[1]; enabledReminders.push({ slot: 2, time }); } if (group.reminder3Enabled === true) { const time = group.reminder3Time || DEFAULT_GROUP_REMINDER_SLOTS[2]; enabledReminders.push({ slot: 3, time }); } if (enabledReminders.length === 0) { continue; } for (const { slot, time: reminderTime } of enabledReminders) { // Use slot-only key to prevent duplicate reminders when settings change const uniqueKey = `group:${group.groupId}:${cycle.id}:slot${slot}`; // Check if we already have a reminder for this slot const existingReminder = this.scheduledReminders.get(uniqueKey); if (existingReminder) { // If settings changed (different time), cancel old and reschedule const newMinutesBefore = reminderTimeToMinutes(reminderTime); const newReminderDate = new Date(drawTime.getTime() - newMinutesBefore * 60 * 1000); // Only reschedule if the time actually changed if (existingReminder.scheduledFor.getTime() !== newReminderDate.getTime()) { clearTimeout(existingReminder.timeout); this.scheduledReminders.delete(uniqueKey); logger.debug('Cleared old reminder due to settings change', { groupId: group.groupId, slot, oldTime: existingReminder.scheduledFor.toISOString(), newTime: newReminderDate.toISOString(), }); } else { continue; // Same time, skip } } const minutesBefore = reminderTimeToMinutes(reminderTime); const reminderDate = new Date(drawTime.getTime() - minutesBefore * 60 * 1000); if (reminderDate <= now) { continue; } const delay = reminderDate.getTime() - now.getTime(); const timeout = setTimeout(async () => { await this.sendGroupReminder(group, cycle, reminderTime, drawTime); this.scheduledReminders.delete(uniqueKey); }, delay); this.scheduledReminders.set(uniqueKey, { groupId: group.groupId, cycleId: cycle.id, reminderKey: `slot${slot}_${formatReminderTime(reminderTime)}`, scheduledFor: reminderDate, timeout, }); logger.debug('Scheduled group reminder', { groupId: group.groupId, cycleId: cycle.id, slot, time: formatReminderTime(reminderTime), scheduledFor: reminderDate.toISOString(), }); } } } /** * Schedule reminders for individual users with drawReminders enabled */ private async scheduleUserReminders(cycle: CycleInfo): Promise { if (!this.bot || cycle.status === 'completed' || cycle.status === 'cancelled') { return; } const drawTime = new Date(cycle.scheduled_at); const now = new Date(); // Get users with draw reminders enabled const users = await stateManager.getUsersWithNotification('drawReminders'); // Default reminder: 15 minutes before const defaultReminder: ReminderTime = { value: 15, unit: 'minutes' }; const reminderKey = formatReminderTime(defaultReminder); for (const user of users) { const uniqueKey = `user:${user.telegramId}:${cycle.id}:${reminderKey}`; if (this.scheduledReminders.has(uniqueKey)) { continue; } const minutesBefore = reminderTimeToMinutes(defaultReminder); const reminderDate = new Date(drawTime.getTime() - minutesBefore * 60 * 1000); if (reminderDate <= now) { continue; } const delay = reminderDate.getTime() - now.getTime(); const timeout = setTimeout(async () => { await this.sendUserReminder(user, cycle, defaultReminder, drawTime); this.scheduledReminders.delete(uniqueKey); }, delay); this.scheduledReminders.set(uniqueKey, { telegramId: user.telegramId, cycleId: cycle.id, reminderKey, scheduledFor: reminderDate, timeout, }); } } /** * Send a reminder to a group */ private async sendGroupReminder( group: GroupSettings, cycle: CycleInfo, reminderTime: ReminderTime, drawTime: Date ): Promise { if (!this.bot) return; try { const message = messages.notifications.drawReminder( reminderTime.value, reminderTime.unit, drawTime, cycle.pot_total_sats ); await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' }); logger.info('Sent draw reminder to group', { groupId: group.groupId, reminderKey: formatReminderTime(reminderTime) }); } catch (error) { this.handleSendError(error, group.groupId); } } /** * Send a reminder to a user */ private async sendUserReminder( user: TelegramUser, cycle: CycleInfo, reminderTime: ReminderTime, drawTime: Date ): Promise { if (!this.bot) return; try { const message = messages.notifications.drawReminder( reminderTime.value, reminderTime.unit, drawTime, cycle.pot_total_sats ); await this.bot.sendMessage(user.telegramId, message, { parse_mode: 'Markdown' }); logger.info('Sent draw reminder to user', { telegramId: user.telegramId }); } catch (error) { logger.error('Failed to send reminder to user', { telegramId: user.telegramId, error }); } } /** * Handle send errors (remove group if bot was kicked) */ private handleSendError(error: any, groupId: number): void { logger.error('Failed to send message to group', { groupId, error }); if (error?.response?.statusCode === 403) { groupStateManager.removeGroup(groupId); } } /** * Clear all scheduled reminders for a cycle */ private clearRemindersForCycle(cycleId: string): void { for (const [key, reminder] of this.scheduledReminders.entries()) { if (reminder.cycleId === cycleId) { clearTimeout(reminder.timeout); this.scheduledReminders.delete(key); } } } /** * Get status info for debugging */ getStatus(): object { return { isRunning: this.isRunning, lastCycleId: this.lastCycleId, lastCycleStatus: this.lastCycleStatus, scheduledReminders: this.scheduledReminders.size, announcedCycles: this.announcedCycles.size, }; } } export const notificationScheduler = new NotificationScheduler(); export default notificationScheduler;