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
This commit is contained in:
Michilis
2025-12-08 23:49:54 +00:00
parent 13fd2b8989
commit 86e2e0a321
10 changed files with 366 additions and 51 deletions

View File

@@ -6,6 +6,7 @@ import {
GroupSettings,
REMINDER_PRESETS,
ANNOUNCEMENT_DELAY_OPTIONS,
NEW_JACKPOT_DELAY_OPTIONS,
DEFAULT_GROUP_REMINDER_SLOTS,
ReminderTime,
formatReminderTime,
@@ -268,6 +269,20 @@ export async function handleGroupSettingsCallback(
}
}
// Handle new jackpot delay selection
if (action.startsWith('newjackpot_delay_')) {
const minutes = parseInt(action.replace('newjackpot_delay_', ''), 10);
if (!isNaN(minutes)) {
updatedSettings = await groupStateManager.updateNewJackpotDelay(chatId, minutes);
if (updatedSettings) {
logUserAction(userId, 'Updated new jackpot delay', { groupId: chatId, minutes });
await bot.answerCallbackQuery(query.id, {
text: minutes === 0 ? 'Announce immediately' : `Announce ${minutes} min after new jackpot`
});
}
}
}
// Handle reminder time adjustments (reminder1_add_1_hours, reminder2_sub_1_days, etc.)
const reminderTimeMatch = action.match(/^reminder(\d)_(add|sub)_(\d+)_(minutes|hours|days)$/);
if (reminderTimeMatch) {
@@ -334,7 +349,7 @@ export async function handleGroupSettingsCallback(
/**
* Format delay option for display
* Format delay option for display (seconds)
*/
function formatDelayOption(seconds: number): string {
if (seconds === 0) return 'Instant';
@@ -345,6 +360,14 @@ function formatDelayOption(seconds: number): string {
return `${seconds}s`;
}
/**
* Format new jackpot delay option for display (minutes)
*/
function formatNewJackpotDelay(minutes: number): string {
if (minutes === 0) return 'Instant';
return minutes === 1 ? '1 min' : `${minutes} min`;
}
/**
* Get time adjustment buttons for a reminder slot
*/
@@ -385,12 +408,23 @@ function getGroupSettingsKeyboard(settings: GroupSettings): TelegramBot.InlineKe
text: `${onOff(settings.newJackpotAnnouncement)} New Jackpot Announcement`,
callback_data: 'group_toggle_newjackpot',
}],
[{
text: `${onOff(settings.drawAnnouncements)} Draw Result Announcements`,
callback_data: 'group_toggle_announcements',
}],
];
// Add new jackpot delay options if enabled
if (settings.newJackpotAnnouncement !== false) {
keyboard.push(
NEW_JACKPOT_DELAY_OPTIONS.map(minutes => ({
text: `${selected(settings.newJackpotDelayMinutes ?? 5, minutes)} ${formatNewJackpotDelay(minutes)}`,
callback_data: `group_newjackpot_delay_${minutes}`,
}))
);
}
keyboard.push([{
text: `${onOff(settings.drawAnnouncements)} Draw Result Announcements`,
callback_data: 'group_toggle_announcements',
}]);
// Add announcement delay options if announcements are enabled
if (settings.drawAnnouncements) {
keyboard.push(

View File

@@ -354,6 +354,13 @@ bot.on('callback_query', async (query) => {
return;
}
// Handle new jackpot announcement delay selection
if (data.startsWith('group_newjackpot_delay_')) {
const action = data.replace('group_', '');
await handleGroupSettingsCallback(bot, query, action);
return;
}
// Handle group refresh
if (data === 'group_refresh') {
await handleGroupRefresh(bot, query);

View File

@@ -603,6 +603,7 @@ To buy tickets privately, message me directly! 🎟`,
reminder3Enabled?: boolean;
reminder3Time?: { value: number; unit: string };
announcementDelaySeconds?: number;
newJackpotDelayMinutes?: number;
}) => {
const announceDelay = settings.announcementDelaySeconds ?? 10;
const formatAnnounce = announceDelay === 0
@@ -610,6 +611,11 @@ To buy tickets privately, message me directly! 🎟`,
: announceDelay >= 60
? `${announceDelay / 60} min after draw`
: `${announceDelay}s after draw`;
const newJackpotDelay = settings.newJackpotDelayMinutes ?? 5;
const formatNewJackpotDelay = newJackpotDelay === 0
? 'Immediately'
: `${newJackpotDelay} min after start`;
// Format helper for reminder times
const formatTime = (t?: { value: number; unit: string }) => {
@@ -639,7 +645,7 @@ To buy tickets privately, message me directly! 🎟`,
*Current Configuration:*
${settings.enabled ? '✅' : '❌'} Bot Enabled
${newJackpot ? '✅' : '❌'} New Jackpot Announcements
${newJackpot ? '✅' : '❌'} New Jackpot Announcements ${newJackpot ? `_(${formatNewJackpotDelay})_` : ''}
${settings.drawAnnouncements ? '✅' : '❌'} Draw Announcements ${settings.drawAnnouncements ? `_(${formatAnnounce})_` : ''}
${settings.reminders ? '✅' : '❌'} Draw Reminders ${settings.reminders ? `_(${formatReminderList})_` : ''}
${settings.ticketPurchaseAllowed ? '✅' : '❌'} Ticket Purchases in Group

View File

@@ -105,6 +105,7 @@ class BotDatabase {
reminder3_enabled INTEGER DEFAULT 0,
reminder3_time TEXT DEFAULT '{"value":6,"unit":"days"}',
announcement_delay_seconds INTEGER DEFAULT 10,
new_jackpot_delay_minutes INTEGER DEFAULT 5,
added_by INTEGER,
added_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
@@ -515,6 +516,7 @@ class BotDatabase {
reminder3_enabled = ?,
reminder3_time = ?,
announcement_delay_seconds = ?,
new_jackpot_delay_minutes = ?,
updated_at = CURRENT_TIMESTAMP
WHERE group_id = ?
`).run(
@@ -531,6 +533,7 @@ class BotDatabase {
settings.reminder3Enabled ? 1 : 0,
JSON.stringify(settings.reminder3Time),
settings.announcementDelaySeconds,
settings.newJackpotDelayMinutes ?? 5,
settings.groupId
);
}
@@ -580,6 +583,18 @@ class BotDatabase {
return this.getGroup(groupId);
}
/**
* Update new jackpot announcement delay
*/
updateNewJackpotDelay(groupId: number, minutes: number): GroupSettings | null {
const group = this.getGroup(groupId);
if (!group) return null;
group.newJackpotDelayMinutes = minutes;
this.saveGroup(group);
return this.getGroup(groupId);
}
/**
* Get groups with a specific feature enabled
*/
@@ -627,6 +642,7 @@ class BotDatabase {
reminder3Time: row.reminder3_time ? JSON.parse(row.reminder3_time) : { value: 6, unit: 'days' },
reminderTimes: [], // Legacy field
announcementDelaySeconds: row.announcement_delay_seconds || 10,
newJackpotDelayMinutes: row.new_jackpot_delay_minutes ?? 5,
addedBy: row.added_by,
addedAt: new Date(row.added_at),
updatedAt: new Date(row.updated_at),

View File

@@ -81,6 +81,16 @@ class GroupStateManager {
return botDatabase.updateAnnouncementDelay(groupId, seconds);
}
/**
* Update new jackpot announcement delay
*/
async updateNewJackpotDelay(
groupId: number,
minutes: number
): Promise<GroupSettings | null> {
return botDatabase.updateNewJackpotDelay(groupId, minutes);
}
/**
* Get groups with specific feature enabled
*/

View File

@@ -270,26 +270,34 @@ class NotificationScheduler {
this.announcedCycles.add(`new:${cycle.id}`);
const drawTime = new Date(cycle.scheduled_at);
const bot = this.bot;
// Send to groups
// Send to groups (with configurable delay)
const groups = await groupStateManager.getGroupsWithFeature('enabled');
for (const group of groups) {
if (group.newJackpotAnnouncement === false) continue;
try {
const message = messages.notifications.newJackpot(
lottery.name,
lottery.ticket_price_sats,
drawTime
);
await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
logger.debug('Sent new jackpot announcement to group', { groupId: group.groupId });
} catch (error) {
this.handleSendError(error, group.groupId);
}
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
// Send to users with new jackpot alerts enabled (immediate)
const users = await stateManager.getUsersWithNotification('newJackpotAlerts');
for (const user of users) {
try {
@@ -305,7 +313,7 @@ class NotificationScheduler {
}
}
logger.info('New jackpot announcements sent', { cycleId: cycle.id });
logger.info('New jackpot announcements scheduled/sent', { cycleId: cycle.id });
}
/**
@@ -477,11 +485,29 @@ class NotificationScheduler {
}
for (const { slot, time: reminderTime } of enabledReminders) {
const reminderKey = `slot${slot}_${formatReminderTime(reminderTime)}`;
const uniqueKey = `group:${group.groupId}:${cycle.id}:${reminderKey}`;
// Use slot-only key to prevent duplicate reminders when settings change
const uniqueKey = `group:${group.groupId}:${cycle.id}:slot${slot}`;
if (this.scheduledReminders.has(uniqueKey)) {
continue;
// 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);
@@ -501,7 +527,7 @@ class NotificationScheduler {
this.scheduledReminders.set(uniqueKey, {
groupId: group.groupId,
cycleId: cycle.id,
reminderKey,
reminderKey: `slot${slot}_${formatReminderTime(reminderTime)}`,
scheduledFor: reminderDate,
timeout,
});
@@ -510,7 +536,7 @@ class NotificationScheduler {
groupId: group.groupId,
cycleId: cycle.id,
slot,
reminderKey,
time: formatReminderTime(reminderTime),
scheduledFor: reminderDate.toISOString(),
});
}

View File

@@ -27,6 +27,7 @@ export interface GroupSettings {
// Legacy field (kept for backwards compat, no longer used)
reminderTimes: ReminderTime[];
announcementDelaySeconds: number; // Delay after draw to send announcement (in seconds)
newJackpotDelayMinutes: number; // Delay after new jackpot starts to send announcement (in minutes)
addedBy: number;
addedAt: Date;
updatedAt: Date;
@@ -84,6 +85,11 @@ export function formatReminderTime(rt: ReminderTime): string {
*/
export const ANNOUNCEMENT_DELAY_OPTIONS = [0, 10, 30, 60, 120];
/**
* Available new jackpot announcement delay options (minutes after start)
*/
export const NEW_JACKPOT_DELAY_OPTIONS = [0, 1, 5, 10, 15, 30];
/**
* Default group settings
*/
@@ -101,6 +107,7 @@ export const DEFAULT_GROUP_SETTINGS: Omit<GroupSettings, 'groupId' | 'groupTitle
reminder3Time: { value: 6, unit: 'days' }, // Default: 6 days before
reminderTimes: [], // Legacy field
announcementDelaySeconds: 10, // Default: 10 seconds after draw
newJackpotDelayMinutes: 5, // Default: 5 minutes after new jackpot starts
};