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

162
README.md
View File

@@ -1,4 +1,4 @@
# ⚡ Lightning Lottery # ⚡ Lightning Lotto
A complete Bitcoin Lightning Network powered lottery system with instant payouts to Lightning Addresses. A complete Bitcoin Lightning Network powered lottery system with instant payouts to Lightning Addresses.
@@ -10,24 +10,27 @@ A complete Bitcoin Lightning Network powered lottery system with instant payouts
- 🌐 **Anonymous or Nostr**: Buy tickets anonymously or login with Nostr - 🌐 **Anonymous or Nostr**: Buy tickets anonymously or login with Nostr
- 📱 **Mobile First**: Beautiful responsive design optimized for all devices - 📱 **Mobile First**: Beautiful responsive design optimized for all devices
- 🏆 **Automatic Payouts**: Winners get paid automatically to their Lightning Address - 🏆 **Automatic Payouts**: Winners get paid automatically to their Lightning Address
-**Multiple Cycles**: Support for hourly, daily, weekly, and monthly draws -**Flexible Cycles**: Configurable draw intervals (minutes, hours, days, weekly, or cron)
- 🔄 **Auto-Redraw**: If payout fails, automatically selects a new winner - 🔄 **Auto-Redraw**: If payout fails, automatically selects a new winner
- 🎰 **Random Tickets**: Ticket numbers are randomly generated, not sequential - 🎰 **Random Tickets**: Ticket numbers are randomly generated, not sequential
- 📊 **Swagger Docs**: Full API documentation at `/api-docs` - 📊 **Swagger Docs**: Full API documentation at `/api-docs`
- 🤖 **Telegram Bot**: Full-featured bot for buying tickets and receiving notifications
## Architecture ## Architecture
The system consists of three main components: The system consists of four main components:
1. **Backend API** (Node.js + TypeScript + Express) 1. **Backend API** (Node.js + TypeScript + Express)
2. **Frontend** (Next.js + React + TypeScript + TailwindCSS) 2. **Frontend** (Next.js + React + TypeScript + TailwindCSS)
3. **Database** (PostgreSQL or SQLite) 3. **Telegram Bot** (Node.js + TypeScript + node-telegram-bot-api)
4. **Database** (PostgreSQL or SQLite)
### Backend ### Backend
- RESTful API with comprehensive endpoints - RESTful API with comprehensive endpoints
- LNbits integration for Lightning payments - LNbits integration for Lightning payments
- Automated scheduler for draws and cycle generation - Automated scheduler for draws and cycle generation
- Configurable cycle types (minutes, hours, days, weekly, cron)
- JWT authentication for Nostr users - JWT authentication for Nostr users
- Admin API for manual operations - Admin API for manual operations
- Payment monitoring with polling fallback - Payment monitoring with polling fallback
@@ -36,13 +39,23 @@ The system consists of three main components:
### Frontend ### Frontend
- Server-side rendered Next.js application - Server-side rendered Next.js application
- Redux state management
- Real-time countdown timers - Real-time countdown timers
- Invoice QR code display with payment animations - Invoice QR code display with payment animations
- Automatic status polling - Automatic status polling
- Nostr NIP-07 authentication - Nostr NIP-07 authentication
- Draw animation with winner reveal - Draw animation with winner reveal
- Past winners display - Past winners display
- Fully responsive mobile-first design
### Telegram Bot
- Buy tickets directly from Telegram
- Lightning Address management (with 21Tipbot & Bittip integration)
- View tickets and wins
- Notification preferences (draw reminders, results, new jackpot alerts)
- Group support with admin controls
- Customizable reminders (up to 3 reminder slots per group)
- SQLite database for persistent user/group settings
## Quick Start ## Quick Start
@@ -58,7 +71,8 @@ cd LightningLotto
```bash ```bash
cp back_end/env.example back_end/.env cp back_end/env.example back_end/.env
cp front_end/env.example front_end/.env.local cp front_end/env.example front_end/.env.local
# Edit both files with your configuration cp telegram_bot/env.example telegram_bot/.env
# Edit all files with your configuration
``` ```
3. Start services: 3. Start services:
@@ -113,6 +127,22 @@ npm run build
npm start npm start
``` ```
#### Telegram Bot
```bash
cd telegram_bot
npm install
cp env.example .env
# Edit .env with your bot token and API URL
# Run development server
npm run dev
# Or build and run production
npm run build
npm start
```
## Configuration ## Configuration
### Required Environment Variables ### Required Environment Variables
@@ -138,6 +168,15 @@ ADMIN_API_KEY=your-admin-api-key
DEFAULT_TICKET_PRICE_SATS=1000 DEFAULT_TICKET_PRICE_SATS=1000
DEFAULT_HOUSE_FEE_PERCENT=5 DEFAULT_HOUSE_FEE_PERCENT=5
PAYOUT_MAX_ATTEMPTS_BEFORE_REDRAW=2 PAYOUT_MAX_ATTEMPTS_BEFORE_REDRAW=2
# Cycle Configuration (choose one type)
CYCLE_TYPE=hours # minutes | hours | days | weekly | cron
CYCLE_INTERVAL_MINUTES=30 # For 'minutes' type
CYCLE_INTERVAL_HOURS=1 # For 'hours' type
CYCLE_DAILY_TIME=20:00 # For 'days' type (HH:MM UTC)
CYCLE_WEEKLY_DAY=saturday # For 'weekly' type
CYCLE_WEEKLY_TIME=20:00 # For 'weekly' type (HH:MM UTC)
CYCLE_CRON_EXPRESSION=0 */6 * * * # For 'cron' type
``` ```
#### Frontend (.env.local) #### Frontend (.env.local)
@@ -146,7 +185,16 @@ PAYOUT_MAX_ATTEMPTS_BEFORE_REDRAW=2
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000 NEXT_PUBLIC_API_BASE_URL=http://localhost:3000
``` ```
See `back_end/env.example` and `front_end/env.example` for all configuration options. #### Telegram Bot (.env)
```bash
TELEGRAM_BOT_TOKEN=your-telegram-bot-token
API_BASE_URL=http://localhost:3000
FRONTEND_BASE_URL=http://localhost:3001
# BOT_DATABASE_PATH=./data/bot.db # Optional, defaults to ./data/bot.db
```
See `back_end/env.example`, `front_end/env.example`, and `telegram_bot/env.example` for all configuration options.
## API Endpoints ## API Endpoints
@@ -186,9 +234,39 @@ See `back_end/env.example` and `front_end/env.example` for all configuration opt
Full API documentation available at `/api-docs` (Swagger UI). Full API documentation available at `/api-docs` (Swagger UI).
## Telegram Bot Commands
### Private Chat Commands
| Command | Description |
|---------|-------------|
| `/start` | Start the bot and register |
| `/lottomenu` | Show main menu |
| `/buyticket` | Buy lottery tickets |
| `/tickets` | View your tickets |
| `/wins` | View your wins |
| `/lottoaddress` | Set your Lightning Address |
| `/lottosettings` | Notification settings |
| `/lottohelp` | Show help |
### Group Commands (Admin Only)
| Command | Description |
|---------|-------------|
| `/lottosettings` | Configure group lottery settings |
| `/jackpot` | Show current jackpot info |
### Group Features
- Enable/disable draw announcements
- Enable/disable new jackpot alerts
- Up to 3 customizable draw reminders
- Adjustable reminder times (minutes/hours/days)
- Auto-deleting settings messages (2 min TTL)
## Database Schema ## Database Schema
7 main tables: ### Backend Database (7 main tables)
- `lotteries` - Lottery configuration - `lotteries` - Lottery configuration
- `jackpot_cycles` - Draw cycles with status and winners - `jackpot_cycles` - Draw cycles with status and winners
@@ -198,15 +276,23 @@ Full API documentation available at `/api-docs` (Swagger UI).
- `users` - Nostr user accounts (optional) - `users` - Nostr user accounts (optional)
- `draw_logs` - Audit trail for transparency - `draw_logs` - Audit trail for transparency
### Telegram Bot Database (SQLite)
- `users` - Telegram user profiles and notification preferences
- `user_purchases` - Tracks purchases per user
- `cycle_participants` - Tracks participants per cycle
- `groups` - Group settings and reminder configurations
## How It Works ## How It Works
1. **Cycle Generation**: Scheduler automatically creates future draw cycles 1. **Cycle Generation**: Scheduler automatically creates future draw cycles
2. **Ticket Purchase**: Users buy tickets, receive Lightning invoice 2. **Ticket Purchase**: Users buy tickets via web or Telegram, receive Lightning invoice
3. **Payment Processing**: LNbits webhook or polling confirms payment 3. **Payment Processing**: LNbits webhook or polling confirms payment
4. **Ticket Issuance**: Random ticket numbers assigned in database transaction 4. **Ticket Issuance**: Random ticket numbers assigned in database transaction
5. **Draw Execution**: At scheduled time, winner selected using CSPRNG 5. **Draw Execution**: At scheduled time, winner selected using CSPRNG
6. **Payout**: Winner's Lightning Address paid automatically 6. **Notifications**: Telegram bot sends draw results to participants and groups
7. **Retry/Redraw**: Failed payouts retried; new winner drawn after max attempts 7. **Payout**: Winner's Lightning Address paid automatically
8. **Retry/Redraw**: Failed payouts retried; new winner drawn after max attempts
## Security Features ## Security Features
@@ -219,6 +305,7 @@ Full API documentation available at `/api-docs` (Swagger UI).
- `crypto.randomBytes()` for winner selection - `crypto.randomBytes()` for winner selection
- `crypto.randomInt()` for ticket number generation - `crypto.randomInt()` for ticket number generation
- No floating-point math (BIGINT for all sats) - No floating-point math (BIGINT for all sats)
- Lightning Address LNURL verification
## Frontend Pages ## Frontend Pages
@@ -233,21 +320,18 @@ Full API documentation available at `/api-docs` (Swagger UI).
| `/dashboard/tickets` | User's ticket history | | `/dashboard/tickets` | User's ticket history |
| `/dashboard/wins` | User's win history | | `/dashboard/wins` | User's win history |
## Testing ## Production Deployment
### Backend Tests ### Systemd Services & Nginx
```bash
cd back_end
npm test
```
### Frontend Tests The `setup/` folder contains production-ready configuration files:
```bash
cd front_end
npm test
```
## Deployment - `lightning-lotto-backend.service` - Backend systemd service
- `lightning-lotto-frontend.service` - Frontend systemd service
- `lightning-lotto-telegram.service` - Telegram bot systemd service
- `nginx-lightning-lotto.conf` - Nginx reverse proxy with SSL
See `setup/README.md` for detailed deployment instructions.
### Production Considerations ### Production Considerations
@@ -298,11 +382,38 @@ LightningLotto/
│ ├── components/ # React components │ ├── components/ # React components
│ ├── config/ # Frontend config │ ├── config/ # Frontend config
│ ├── constants/ # Text strings │ ├── constants/ # Text strings
── lib/ # API client and utilities ── lib/ # API client and utilities
│ └── store/ # Redux state ├── telegram_bot/
│ ├── src/
│ │ ├── config/ # Bot configuration
│ │ ├── handlers/ # Command and callback handlers
│ │ ├── messages/ # Centralized message strings
│ │ ├── services/ # Database, API, notifications
│ │ ├── types/ # TypeScript types
│ │ └── utils/ # Keyboards, formatting
│ └── data/ # SQLite database for bot
├── setup/ # Production deployment configs
│ ├── *.service # Systemd service files
│ ├── nginx-*.conf # Nginx configuration
│ └── README.md # Deployment guide
├── App_info/ # Documentation
└── docker-compose.yml └── docker-compose.yml
``` ```
## Testing
### Backend Tests
```bash
cd back_end
npm test
```
### Frontend Tests
```bash
cd front_end
npm test
```
## License ## License
MIT License - see LICENSE file for details MIT License - see LICENSE file for details
@@ -311,6 +422,7 @@ MIT License - see LICENSE file for details
- Built with [LNbits](https://lnbits.com/) for Lightning Network integration - Built with [LNbits](https://lnbits.com/) for Lightning Network integration
- Uses [Nostr](https://nostr.com/) for decentralized authentication - Uses [Nostr](https://nostr.com/) for decentralized authentication
- Telegram bot integration for mobile-first experience
- Inspired by the Bitcoin Lightning Network community - Inspired by the Bitcoin Lightning Network community
--- ---

View File

@@ -242,6 +242,8 @@ async function checkAndExecuteDraws(): Promise<void> {
for (const cycle of result.rows) { for (const cycle of result.rows) {
console.log(`Executing draw for cycle ${cycle.id} (${cycle.cycle_type})`); console.log(`Executing draw for cycle ${cycle.id} (${cycle.cycle_type})`);
await executeDraw(cycle.id); await executeDraw(cycle.id);
// Cancel unpaid invoices for this cycle after draw
await cancelUnpaidPurchases(cycle.id);
} }
} catch (error) { } catch (error) {
@@ -249,6 +251,30 @@ async function checkAndExecuteDraws(): Promise<void> {
} }
} }
/**
* Cancel unpaid purchases after a draw completes
* This prevents payments for invoices from past rounds
*/
async function cancelUnpaidPurchases(cycleId: string): Promise<void> {
try {
const result = await db.query(
`UPDATE ticket_purchases
SET status = 'cancelled', updated_at = NOW()
WHERE cycle_id = $1
AND status = 'pending'
RETURNING id`,
[cycleId]
);
const cancelledCount = result.rows.length;
if (cancelledCount > 0) {
console.log(`Cancelled ${cancelledCount} unpaid purchases for cycle ${cycleId}`);
}
} catch (error) {
console.error('Error cancelling unpaid purchases:', error);
}
}
/** /**
* Update cycles to 'sales_open' status when appropriate * Update cycles to 'sales_open' status when appropriate
*/ */

View File

@@ -0,0 +1,71 @@
================================
BOTFATHER COMMAND SETUP
================================
Send to @BotFather: /setcommands
Then select your bot and paste the commands below:
================================
PRIVATE CHAT COMMANDS
================================
start - Start the bot and register
lottomenu - Show main menu
buyticket - Buy lottery tickets
tickets - View your tickets
wins - View your wins
lottoaddress - Set your Lightning Address
lottosettings - Notification settings
lottohelp - Show help
jackpot - Show current jackpot
================================
GROUP COMMANDS (Optional)
================================
If you want separate commands for groups, use /setcommands again
and select "Edit commands for groups":
jackpot - Show current jackpot info
lottosettings - Configure lottery settings (admin only)
lottohelp - Show help and commands
================================
BOT DESCRIPTION
================================
Send to @BotFather: /setdescription
Lightning Lotto - Win Bitcoin on the Lightning Network! ⚡
Buy lottery tickets with Lightning, get instant payouts if you win.
• Buy tickets with sats
• Set your Lightning Address for payouts
• Get notified about draws and wins
• Join groups to get jackpot alerts
================================
ABOUT TEXT
================================
Send to @BotFather: /setabouttext
⚡ Lightning Lotto Bot ⚡
A provably fair Bitcoin lottery powered by the Lightning Network.
🎟️ Buy tickets with Lightning
💰 Instant payouts to your Lightning Address
🔔 Draw reminders & win notifications
👥 Group support with announcements
Start with /start to begin!
================================
SHORT DESCRIPTION
================================
Send to @BotFather: /setshortdescription
Win Bitcoin on the Lightning Network! Buy lottery tickets, get instant payouts. ⚡

View File

@@ -6,6 +6,7 @@ import {
GroupSettings, GroupSettings,
REMINDER_PRESETS, REMINDER_PRESETS,
ANNOUNCEMENT_DELAY_OPTIONS, ANNOUNCEMENT_DELAY_OPTIONS,
NEW_JACKPOT_DELAY_OPTIONS,
DEFAULT_GROUP_REMINDER_SLOTS, DEFAULT_GROUP_REMINDER_SLOTS,
ReminderTime, ReminderTime,
formatReminderTime, 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.) // 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)$/); const reminderTimeMatch = action.match(/^reminder(\d)_(add|sub)_(\d+)_(minutes|hours|days)$/);
if (reminderTimeMatch) { 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 { function formatDelayOption(seconds: number): string {
if (seconds === 0) return 'Instant'; if (seconds === 0) return 'Instant';
@@ -345,6 +360,14 @@ function formatDelayOption(seconds: number): string {
return `${seconds}s`; 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 * Get time adjustment buttons for a reminder slot
*/ */
@@ -385,11 +408,22 @@ function getGroupSettingsKeyboard(settings: GroupSettings): TelegramBot.InlineKe
text: `${onOff(settings.newJackpotAnnouncement)} New Jackpot Announcement`, text: `${onOff(settings.newJackpotAnnouncement)} New Jackpot Announcement`,
callback_data: 'group_toggle_newjackpot', callback_data: 'group_toggle_newjackpot',
}], }],
[{ ];
// 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`, text: `${onOff(settings.drawAnnouncements)} Draw Result Announcements`,
callback_data: 'group_toggle_announcements', callback_data: 'group_toggle_announcements',
}], }]);
];
// Add announcement delay options if announcements are enabled // Add announcement delay options if announcements are enabled
if (settings.drawAnnouncements) { if (settings.drawAnnouncements) {

View File

@@ -354,6 +354,13 @@ bot.on('callback_query', async (query) => {
return; 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 // Handle group refresh
if (data === 'group_refresh') { if (data === 'group_refresh') {
await handleGroupRefresh(bot, query); await handleGroupRefresh(bot, query);

View File

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

View File

@@ -105,6 +105,7 @@ class BotDatabase {
reminder3_enabled INTEGER DEFAULT 0, reminder3_enabled INTEGER DEFAULT 0,
reminder3_time TEXT DEFAULT '{"value":6,"unit":"days"}', reminder3_time TEXT DEFAULT '{"value":6,"unit":"days"}',
announcement_delay_seconds INTEGER DEFAULT 10, announcement_delay_seconds INTEGER DEFAULT 10,
new_jackpot_delay_minutes INTEGER DEFAULT 5,
added_by INTEGER, added_by INTEGER,
added_at TEXT DEFAULT CURRENT_TIMESTAMP, added_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP updated_at TEXT DEFAULT CURRENT_TIMESTAMP
@@ -515,6 +516,7 @@ class BotDatabase {
reminder3_enabled = ?, reminder3_enabled = ?,
reminder3_time = ?, reminder3_time = ?,
announcement_delay_seconds = ?, announcement_delay_seconds = ?,
new_jackpot_delay_minutes = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE group_id = ? WHERE group_id = ?
`).run( `).run(
@@ -531,6 +533,7 @@ class BotDatabase {
settings.reminder3Enabled ? 1 : 0, settings.reminder3Enabled ? 1 : 0,
JSON.stringify(settings.reminder3Time), JSON.stringify(settings.reminder3Time),
settings.announcementDelaySeconds, settings.announcementDelaySeconds,
settings.newJackpotDelayMinutes ?? 5,
settings.groupId settings.groupId
); );
} }
@@ -580,6 +583,18 @@ class BotDatabase {
return this.getGroup(groupId); 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 * 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' }, reminder3Time: row.reminder3_time ? JSON.parse(row.reminder3_time) : { value: 6, unit: 'days' },
reminderTimes: [], // Legacy field reminderTimes: [], // Legacy field
announcementDelaySeconds: row.announcement_delay_seconds || 10, announcementDelaySeconds: row.announcement_delay_seconds || 10,
newJackpotDelayMinutes: row.new_jackpot_delay_minutes ?? 5,
addedBy: row.added_by, addedBy: row.added_by,
addedAt: new Date(row.added_at), addedAt: new Date(row.added_at),
updatedAt: new Date(row.updated_at), updatedAt: new Date(row.updated_at),

View File

@@ -81,6 +81,16 @@ class GroupStateManager {
return botDatabase.updateAnnouncementDelay(groupId, seconds); 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 * Get groups with specific feature enabled
*/ */

View File

@@ -270,26 +270,34 @@ class NotificationScheduler {
this.announcedCycles.add(`new:${cycle.id}`); this.announcedCycles.add(`new:${cycle.id}`);
const drawTime = new Date(cycle.scheduled_at); 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'); const groups = await groupStateManager.getGroupsWithFeature('enabled');
for (const group of groups) { for (const group of groups) {
if (group.newJackpotAnnouncement === false) continue; if (group.newJackpotAnnouncement === false) continue;
const delayMs = (group.newJackpotDelayMinutes ?? 5) * 60 * 1000;
setTimeout(async () => {
try { try {
const message = messages.notifications.newJackpot( const message = messages.notifications.newJackpot(
lottery.name, lottery.name,
lottery.ticket_price_sats, lottery.ticket_price_sats,
drawTime drawTime
); );
await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' }); await bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
logger.debug('Sent new jackpot announcement to group', { groupId: group.groupId }); logger.debug('Sent new jackpot announcement to group', {
groupId: group.groupId,
delayMinutes: group.newJackpotDelayMinutes ?? 5
});
} catch (error) { } catch (error) {
this.handleSendError(error, group.groupId); 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'); const users = await stateManager.getUsersWithNotification('newJackpotAlerts');
for (const user of users) { for (const user of users) {
try { 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) { for (const { slot, time: reminderTime } of enabledReminders) {
const reminderKey = `slot${slot}_${formatReminderTime(reminderTime)}`; // Use slot-only key to prevent duplicate reminders when settings change
const uniqueKey = `group:${group.groupId}:${cycle.id}:${reminderKey}`; const uniqueKey = `group:${group.groupId}:${cycle.id}:slot${slot}`;
if (this.scheduledReminders.has(uniqueKey)) { // Check if we already have a reminder for this slot
continue; 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 minutesBefore = reminderTimeToMinutes(reminderTime);
@@ -501,7 +527,7 @@ class NotificationScheduler {
this.scheduledReminders.set(uniqueKey, { this.scheduledReminders.set(uniqueKey, {
groupId: group.groupId, groupId: group.groupId,
cycleId: cycle.id, cycleId: cycle.id,
reminderKey, reminderKey: `slot${slot}_${formatReminderTime(reminderTime)}`,
scheduledFor: reminderDate, scheduledFor: reminderDate,
timeout, timeout,
}); });
@@ -510,7 +536,7 @@ class NotificationScheduler {
groupId: group.groupId, groupId: group.groupId,
cycleId: cycle.id, cycleId: cycle.id,
slot, slot,
reminderKey, time: formatReminderTime(reminderTime),
scheduledFor: reminderDate.toISOString(), scheduledFor: reminderDate.toISOString(),
}); });
} }

View File

@@ -27,6 +27,7 @@ export interface GroupSettings {
// Legacy field (kept for backwards compat, no longer used) // Legacy field (kept for backwards compat, no longer used)
reminderTimes: ReminderTime[]; reminderTimes: ReminderTime[];
announcementDelaySeconds: number; // Delay after draw to send announcement (in seconds) announcementDelaySeconds: number; // Delay after draw to send announcement (in seconds)
newJackpotDelayMinutes: number; // Delay after new jackpot starts to send announcement (in minutes)
addedBy: number; addedBy: number;
addedAt: Date; addedAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -84,6 +85,11 @@ export function formatReminderTime(rt: ReminderTime): string {
*/ */
export const ANNOUNCEMENT_DELAY_OPTIONS = [0, 10, 30, 60, 120]; 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 * 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 reminder3Time: { value: 6, unit: 'days' }, // Default: 6 days before
reminderTimes: [], // Legacy field reminderTimes: [], // Legacy field
announcementDelaySeconds: 10, // Default: 10 seconds after draw announcementDelaySeconds: 10, // Default: 10 seconds after draw
newJackpotDelayMinutes: 5, // Default: 5 minutes after new jackpot starts
}; };