Update API client and notification scheduler services

This commit is contained in:
Michilis
2025-12-20 02:26:40 +00:00
parent 1dce27ea42
commit 83298dc4ca
2 changed files with 120 additions and 52 deletions

View File

@@ -157,6 +157,32 @@ class ApiClient {
return { enabled: false, pending: false, message: null }; return { enabled: false, pending: false, message: null };
} }
} }
/**
* Get past wins (for group announcements)
*/
async getPastWins(limit: number = 1, offset: number = 0): Promise<PastWin[]> {
try {
const response = await this.client.get<ApiResponse<{ wins: PastWin[] }>>(
`/jackpot/past-wins?limit=${limit}&offset=${offset}`
);
return response.data.data.wins || [];
} catch (error) {
logger.error('Failed to get past wins', { error });
return [];
}
}
}
export interface PastWin {
cycle_id: string;
cycle_type: string;
scheduled_at: string;
pot_total_sats: number;
pot_after_fee_sats: number | null;
winner_name: string;
winner_address: string | null;
winning_ticket_serial: number | null;
} }
export const apiClient = new ApiClient(); export const apiClient = new ApiClient();

View File

@@ -122,6 +122,7 @@ class NotificationScheduler {
/** /**
* Handle previous cycle completion when we detect a new cycle * Handle previous cycle completion when we detect a new cycle
* Always sends group announcements, even if no participants tracked locally
*/ */
private async handlePreviousCycleCompleted(previousCycleId: string): Promise<void> { private async handlePreviousCycleCompleted(previousCycleId: string): Promise<void> {
if (!this.bot) return; if (!this.bot) return;
@@ -135,19 +136,41 @@ class NotificationScheduler {
// Clear reminders for the old cycle // Clear reminders for the old cycle
this.clearRemindersForCycle(previousCycleId); this.clearRemindersForCycle(previousCycleId);
// Get participants for the previous cycle logger.info('Processing previous cycle completion', { cycleId: previousCycleId });
const participants = await stateManager.getCycleParticipants(previousCycleId);
logger.info('Processing previous cycle completion', { // Fetch winner info from API (works even if no participants tracked locally)
cycleId: previousCycleId, let winnerDisplayName = 'Anon';
participantCount: participants.length let winnerTicketNumber = '0000';
}); let winnerLightningAddress = '';
let potSats = 0;
let totalTickets = 0;
if (participants.length === 0) { try {
return; const pastWins = await apiClient.getPastWins(1, 0);
// Try to find this specific cycle, or use the latest
const latestWin = pastWins.find(w => w.cycle_id === previousCycleId) || pastWins[0];
if (latestWin) {
winnerDisplayName = latestWin.winner_name || 'Anon';
winnerLightningAddress = latestWin.winner_address || '';
potSats = latestWin.pot_total_sats || 0;
if (latestWin.winning_ticket_serial !== null) {
winnerTicketNumber = latestWin.winning_ticket_serial.toString().padStart(4, '0');
}
logger.info('Got winner info from API', {
cycleId: previousCycleId,
winnerName: winnerDisplayName,
potSats
});
}
} catch (error) {
logger.error('Failed to fetch past wins for announcement', { error, cycleId: previousCycleId });
} }
// Group participants by telegramId to avoid spamming users with multiple purchases // Get participants for DM notifications
const participants = await stateManager.getCycleParticipants(previousCycleId);
// Group participants by telegramId
const userPurchases = new Map<number, string[]>(); const userPurchases = new Map<number, string[]>();
for (const participant of participants) { for (const participant of participants) {
const existing = userPurchases.get(participant.telegramId) || []; const existing = userPurchases.get(participant.telegramId) || [];
@@ -155,13 +178,11 @@ class NotificationScheduler {
userPurchases.set(participant.telegramId, existing); userPurchases.set(participant.telegramId, existing);
} }
// First pass: Find the winning ticket info and pot amount totalTickets = userPurchases.size;
let winnerDisplayName = 'Anon';
let winnerTicketNumber = '0000'; // Find winner among tracked participants for DM notifications
let winnerTelegramId: number | null = null; let winnerTelegramId: number | null = null;
let winnerLightningAddress = ''; let prizeSats = potSats;
let potSats = 0;
let prizeSats = 0;
let payoutStatus = 'processing'; let payoutStatus = 'processing';
for (const [telegramId, purchaseIds] of userPurchases) { for (const [telegramId, purchaseIds] of userPurchases) {
@@ -170,13 +191,17 @@ class NotificationScheduler {
const status = await apiClient.getTicketStatus(purchaseId); const status = await apiClient.getTicketStatus(purchaseId);
if (!status) continue; if (!status) continue;
potSats = status.cycle.pot_total_sats || 0; if (status.cycle.pot_total_sats) {
potSats = status.cycle.pot_total_sats;
}
if (status.result.is_winner) { if (status.result.is_winner) {
const user = await stateManager.getUser(telegramId); const user = await stateManager.getUser(telegramId);
winnerTelegramId = telegramId; winnerTelegramId = telegramId;
winnerDisplayName = user ? stateManager.getDisplayName(user) : 'Anon'; if (user) {
winnerLightningAddress = status.purchase.lightning_address || ''; winnerDisplayName = stateManager.getDisplayName(user);
}
winnerLightningAddress = status.purchase.lightning_address || winnerLightningAddress;
const winningTicket = status.tickets.find(t => t.is_winning_ticket); const winningTicket = status.tickets.find(t => t.is_winning_ticket);
if (winningTicket) { if (winningTicket) {
@@ -185,16 +210,16 @@ class NotificationScheduler {
prizeSats = status.result.payout?.amount_sats || potSats; prizeSats = status.result.payout?.amount_sats || potSats;
payoutStatus = status.result.payout?.status || 'processing'; payoutStatus = status.result.payout?.status || 'processing';
break; // Found winner, stop searching break;
} }
} catch (error) { } catch (error) {
logger.error('Error checking purchase status', { purchaseId, error }); logger.error('Error checking purchase status', { purchaseId, error });
} }
} }
if (winnerTelegramId) break; // Found winner, stop searching if (winnerTelegramId) break;
} }
// Second pass: Send ONE notification per user // Send DM notifications to tracked participants
const notifiedUsers = new Set<number>(); const notifiedUsers = new Set<number>();
for (const [telegramId, purchaseIds] of userPurchases) { for (const [telegramId, purchaseIds] of userPurchases) {
@@ -208,7 +233,6 @@ class NotificationScheduler {
const isWinner = telegramId === winnerTelegramId; const isWinner = telegramId === winnerTelegramId;
if (isWinner) { if (isWinner) {
// Send winner notification
await this.bot.sendMessage( await this.bot.sendMessage(
telegramId, telegramId,
messages.notifications.winner( messages.notifications.winner(
@@ -220,7 +244,6 @@ class NotificationScheduler {
); );
logger.info('Sent winner notification', { telegramId }); logger.info('Sent winner notification', { telegramId });
} else { } else {
// Send loser notification with actual winning ticket number
await this.bot.sendMessage( await this.bot.sendMessage(
telegramId, telegramId,
messages.notifications.loser( messages.notifications.loser(
@@ -236,14 +259,13 @@ class NotificationScheduler {
} }
} }
// Send group announcements // ALWAYS send group announcements (regardless of tracked participants)
const uniqueUserCount = userPurchases.size;
await this.sendGroupDrawAnnouncementsImmediate( await this.sendGroupDrawAnnouncementsImmediate(
previousCycleId, previousCycleId,
winnerDisplayName, winnerDisplayName,
winnerTicketNumber, winnerTicketNumber,
potSats, potSats,
uniqueUserCount, totalTickets,
winnerLightningAddress winnerLightningAddress
); );
} }
@@ -351,6 +373,7 @@ class NotificationScheduler {
/** /**
* Handle draw completed - send notifications to participants and groups * Handle draw completed - send notifications to participants and groups
* Always sends group announcements, even if no participants tracked locally
*/ */
private async handleDrawCompleted(cycle: CycleInfo): Promise<void> { private async handleDrawCompleted(cycle: CycleInfo): Promise<void> {
if (!this.bot) return; if (!this.bot) return;
@@ -364,22 +387,43 @@ class NotificationScheduler {
// Clear reminders for this cycle // Clear reminders for this cycle
this.clearRemindersForCycle(cycle.id); this.clearRemindersForCycle(cycle.id);
// Get participants for this cycle
const participants = await stateManager.getCycleParticipants(cycle.id);
logger.info('Processing draw completion', { logger.info('Processing draw completion', {
cycleId: cycle.id, cycleId: cycle.id,
participantCount: participants.length,
potSats: cycle.pot_total_sats potSats: cycle.pot_total_sats
}); });
// Only proceed if there were participants // Fetch winner info from API (works even if no participants tracked locally)
if (participants.length === 0) { let winnerDisplayName = 'Anon';
logger.info('No participants in cycle, skipping notifications', { cycleId: cycle.id }); let winnerTicketNumber = '0000';
return; let winnerLightningAddress = '';
let potSats = cycle.pot_total_sats;
let totalTickets = 0;
try {
const pastWins = await apiClient.getPastWins(1, 0);
const latestWin = pastWins.find(w => w.cycle_id === cycle.id) || pastWins[0];
if (latestWin) {
winnerDisplayName = latestWin.winner_name || 'Anon';
winnerLightningAddress = latestWin.winner_address || '';
potSats = latestWin.pot_total_sats || cycle.pot_total_sats;
if (latestWin.winning_ticket_serial !== null) {
winnerTicketNumber = latestWin.winning_ticket_serial.toString().padStart(4, '0');
}
logger.info('Got winner info from API', {
cycleId: cycle.id,
winnerName: winnerDisplayName,
potSats
});
}
} catch (error) {
logger.error('Failed to fetch past wins for announcement', { error, cycleId: cycle.id });
} }
// Group participants by telegramId to avoid spamming users with multiple purchases // Get participants for DM notifications
const participants = await stateManager.getCycleParticipants(cycle.id);
// Group participants by telegramId
const userPurchases = new Map<number, string[]>(); const userPurchases = new Map<number, string[]>();
for (const participant of participants) { for (const participant of participants) {
const existing = userPurchases.get(participant.telegramId) || []; const existing = userPurchases.get(participant.telegramId) || [];
@@ -387,12 +431,11 @@ class NotificationScheduler {
userPurchases.set(participant.telegramId, existing); userPurchases.set(participant.telegramId, existing);
} }
// First pass: Find the winning ticket info totalTickets = userPurchases.size;
let winnerDisplayName = 'Anon';
let winnerTicketNumber = '0000'; // Find winner among tracked participants for DM notifications
let winnerTelegramId: number | null = null; let winnerTelegramId: number | null = null;
let winnerLightningAddress = ''; let prizeSats = potSats;
let prizeSats = 0;
let payoutStatus = 'processing'; let payoutStatus = 'processing';
for (const [telegramId, purchaseIds] of userPurchases) { for (const [telegramId, purchaseIds] of userPurchases) {
@@ -404,26 +447,28 @@ class NotificationScheduler {
if (status.result.is_winner) { if (status.result.is_winner) {
const user = await stateManager.getUser(telegramId); const user = await stateManager.getUser(telegramId);
winnerTelegramId = telegramId; winnerTelegramId = telegramId;
winnerDisplayName = user ? stateManager.getDisplayName(user) : 'Anon'; if (user) {
winnerLightningAddress = status.purchase.lightning_address || ''; winnerDisplayName = stateManager.getDisplayName(user);
}
winnerLightningAddress = status.purchase.lightning_address || winnerLightningAddress;
const winningTicket = status.tickets.find(t => t.is_winning_ticket); const winningTicket = status.tickets.find(t => t.is_winning_ticket);
if (winningTicket) { if (winningTicket) {
winnerTicketNumber = winningTicket.serial_number.toString().padStart(4, '0'); winnerTicketNumber = winningTicket.serial_number.toString().padStart(4, '0');
} }
prizeSats = status.result.payout?.amount_sats || cycle.pot_total_sats; prizeSats = status.result.payout?.amount_sats || potSats;
payoutStatus = status.result.payout?.status || 'processing'; payoutStatus = status.result.payout?.status || 'processing';
break; // Found winner, stop searching break;
} }
} catch (error) { } catch (error) {
logger.error('Error checking purchase status', { purchaseId, error }); logger.error('Error checking purchase status', { purchaseId, error });
} }
} }
if (winnerTelegramId) break; // Found winner, stop searching if (winnerTelegramId) break;
} }
// Second pass: Send ONE notification per user // Send DM notifications to tracked participants
const notifiedUsers = new Set<number>(); const notifiedUsers = new Set<number>();
for (const [telegramId] of userPurchases) { for (const [telegramId] of userPurchases) {
@@ -437,7 +482,6 @@ class NotificationScheduler {
const isWinner = telegramId === winnerTelegramId; const isWinner = telegramId === winnerTelegramId;
if (isWinner) { if (isWinner) {
// Send winner notification
await this.bot.sendMessage( await this.bot.sendMessage(
telegramId, telegramId,
messages.notifications.winner( messages.notifications.winner(
@@ -449,12 +493,11 @@ class NotificationScheduler {
); );
logger.info('Sent winner notification', { telegramId }); logger.info('Sent winner notification', { telegramId });
} else { } else {
// Send loser notification with actual winning ticket number
await this.bot.sendMessage( await this.bot.sendMessage(
telegramId, telegramId,
messages.notifications.loser( messages.notifications.loser(
winnerTicketNumber, winnerTicketNumber,
cycle.pot_total_sats.toLocaleString() potSats.toLocaleString()
), ),
{ parse_mode: 'Markdown' } { parse_mode: 'Markdown' }
); );
@@ -465,9 +508,8 @@ class NotificationScheduler {
} }
} }
// Send group announcements // ALWAYS send group announcements (regardless of tracked participants)
const uniqueUserCount = userPurchases.size; await this.sendGroupDrawAnnouncements(cycle, winnerDisplayName, winnerTicketNumber, totalTickets, winnerLightningAddress);
await this.sendGroupDrawAnnouncements(cycle, winnerDisplayName, winnerTicketNumber, uniqueUserCount, winnerLightningAddress);
} }
/** /**