Files
LightningLotto/telegram_bot/src/services/notificationScheduler.ts
Michilis 86e2e0a321 Fix reminder scheduling and add group features
- Fix reminder duplicate bug: use slot-only keys to prevent multiple reminders when settings change
- Add New Jackpot announcement delay setting for groups (default 5 min)
- Cancel unpaid purchases after draw completes (prevents payments for past rounds)
- Add BotFather commands template file
- Update README documentation
2025-12-08 23:49:54 +00:00

688 lines
22 KiB
TypeScript

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<string, ScheduledReminder> = new Map();
private lastCycleId: string | null = null;
private lastCycleStatus: string | null = null;
private isRunning = false;
private announcedCycles: Set<string> = 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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;