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:
162
README.md
162
README.md
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
71
telegram_bot/botfather_commands.txt
Normal file
71
telegram_bot/botfather_commands.txt
Normal 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. ⚡
|
||||||
|
|
||||||
@@ -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,12 +408,23 @@ 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',
|
||||||
}],
|
}],
|
||||||
[{
|
|
||||||
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
|
// Add announcement delay options if announcements are enabled
|
||||||
if (settings.drawAnnouncements) {
|
if (settings.drawAnnouncements) {
|
||||||
keyboard.push(
|
keyboard.push(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -610,6 +611,11 @@ To buy tickets privately, message me directly! 🎟`,
|
|||||||
: announceDelay >= 60
|
: announceDelay >= 60
|
||||||
? `${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 }) => {
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
try {
|
const delayMs = (group.newJackpotDelayMinutes ?? 5) * 60 * 1000;
|
||||||
const message = messages.notifications.newJackpot(
|
|
||||||
lottery.name,
|
setTimeout(async () => {
|
||||||
lottery.ticket_price_sats,
|
try {
|
||||||
drawTime
|
const message = messages.notifications.newJackpot(
|
||||||
);
|
lottery.name,
|
||||||
await this.bot.sendMessage(group.groupId, message, { parse_mode: 'Markdown' });
|
lottery.ticket_price_sats,
|
||||||
logger.debug('Sent new jackpot announcement to group', { groupId: group.groupId });
|
drawTime
|
||||||
} catch (error) {
|
);
|
||||||
this.handleSendError(error, group.groupId);
|
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');
|
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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user